yamine 0.13.2 → 0.14.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: 122503332845c1bbee6034e7262b763d38c2d4c849723e9519dfc7e91f0739b6
4
- data.tar.gz: 5ae28759ce2ba01f7c9239056ca49d0ceda6f0958e26a534561fdcab76a485f3
3
+ metadata.gz: ab7c7e888dc8066ad10a7ed2fa90dcd684b27aa171038ac0ae5ea4c06b22251b
4
+ data.tar.gz: '097d12693a71cf3969f481fd0f61b3f82e9a09d7958139e32b3fb79a7e667cd8'
5
5
  SHA512:
6
- metadata.gz: 35f7dc98e1beaa8fcbf46dc2dbb735656789c193d2ed713e395fc038fa9a7801fcec6ea552c3affc67c82f0a3101b3200eb6126459f0588b723245318e257981
7
- data.tar.gz: c3f54ddcc3cb3d0f6b99bca81770fc4addd61cadb22baf176c81042b9609edb079b69f2f41dd265378b08c44eb1a322dc00cffca580496741086750671950007
6
+ metadata.gz: 99ddf961f8fa7e50038cb093b592f692c4c90a94279dc200b9ab7ea9e8a70355e6bf67659bf0cb53aac66d2868d99c6fc138750e490aa1fa4ff5b606bbfacc3f
7
+ data.tar.gz: 825fb69b730125767e18472ca3d7d615514660b63dbcb7aa63697970f6733b1497997b505a6fde9f1e6b0c62785127fac0d2b1332544ff4d535b4076680623b8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] — 2026-09-13
4
+
5
+ ### Added
6
+
7
+ - **yamine now owns the worktree lifecycle, from `add` to `clean`.**
8
+ It already gave every linked worktree its own URL, database, and
9
+ routes; it just never managed the worktree itself. Creating one meant
10
+ a manual `git worktree add`, re-creating the gitignored config by
11
+ hand, a bundle install, and a first boot that discovered everything
12
+ the hard way — and cleanup was hand-run `git worktree remove` with
13
+ routes, databases, and hosts entries left to rot.
14
+
15
+ `yamine worktree add <name>` creates the worktree beside the repo
16
+ (branch = name), copies `config/local.yml` and
17
+ `config/local.secrets` — the gitignored per-checkout files git does
18
+ not carry and the single largest follow-up cost — runs
19
+ `bundle install`, pre-creates the per-worktree database with schema,
20
+ and prints the URL the worktree will serve. `yamine worktree remove
21
+ <name>` is the full teardown of one worktree: stop its backends,
22
+ remove its routes, drop its database, `git worktree remove`, delete
23
+ the branch. `yamine worktree clean [--all] [--dry-run]` is the
24
+ done-and-merged sweep: full teardown of every merged worktree plus
25
+ the old orphan sweep, `list` shows every worktree with its database,
26
+ dirty, and merged status.
27
+
28
+ The safety rails are load-bearing: the main checkout is never a
29
+ candidate; `clean` never touches a worktree with uncommitted changes
30
+ (and disregards only the config yamine itself copied, so a fresh
31
+ worktree is not born dirty); branches go with `git branch -d`, so
32
+ `clean --all` may remove an unmerged worktree but never its branch —
33
+ only `remove --force` discards changes or unmerged refs; a failed
34
+ database drop aborts before the directory is touched, keeping the
35
+ claim for a retry. A sqlite or template-less app needs no server
36
+ drop, so its claims are forgotten instead of failing forever — the
37
+ same honesty 0.13.1 gave postgres, extended to the apps that never
38
+ had a server to begin with.
39
+
3
40
  ## [0.13.2] — 2026-09-12
4
41
 
5
42
  ### Fixed
data/README.md CHANGED
@@ -158,6 +158,32 @@ A variant is a hostname label, not a config file. Only an explicit
158
158
  branch named like a file on disk cannot change your config by accident.
159
159
  `yamine status` prints both lines so the two are never confused.
160
160
 
161
+ ## Worktree lifecycle
162
+
163
+ yamine owns a worktree from creation to removal:
164
+
165
+ ```bash
166
+ yamine worktree add feature/login # worktree + config + database, ready to boot
167
+ yamine worktree list # every worktree: db, dirty, merged
168
+ yamine worktree remove feature/login # stop, drop db, remove worktree
169
+ yamine worktree clean # tear down everything already merged
170
+ ```
171
+
172
+ `add` lands the worktree beside the repo, copies the gitignored
173
+ per-checkout config (`config/local.yml`, `config/local.secrets`) the
174
+ branch needs, runs `bundle install`, and pre-creates the per-worktree
175
+ database with schema — the next step is just `yamine start` in it.
176
+
177
+ `clean` is the done-and-merged sweep: it tears down every worktree
178
+ whose branch is merged (stop, drop database, remove worktree, delete
179
+ branch) and forgets claims of directories that no longer exist. It
180
+ never touches a worktree with uncommitted changes, and unmerged
181
+ branches survive every path except `remove --force` — `git branch -d`
182
+ refuses to delete what git has not seen merged, so a wrong merge
183
+ detection cannot lose a branch. `--all` includes clean-but-unmerged
184
+ worktrees (the branch stays); `--dry-run` prints the plan; `remove
185
+ --force` is the only command that discards uncommitted changes.
186
+
161
187
  ```bash
162
188
  yamine # -> https://myapp.localhost
163
189
  yamine --service api # -> https://api.myapp.localhost
@@ -202,7 +228,7 @@ yamine trust # add local CA to system trust store
202
228
  yamine clean # remove state and hosts entries
203
229
  yamine prune # remove stale routes
204
230
  yamine db list|create|drop # per-worktree databases
205
- yamine worktree list|clean # worktree databases + orphan cleanup
231
+ yamine worktree list|add|remove|clean # worktree lifecycle
206
232
  yamine stop # stop this app's backend + routes
207
233
  yamine restart # touch tmp/restart.txt (managed apps reboot)
208
234
  yamine log [-f] [n] # tail (or follow) this app's backend log
@@ -98,6 +98,26 @@ Only an explicit variant ever looks for an overlay file — a branch name
98
98
  that happens to match one on disk is ignored. `yamine status` shows
99
99
  `variant:` and `overlay:` separately.
100
100
 
101
+ ## Worktree lifecycle
102
+
103
+ ```bash
104
+ yamine worktree add feature/login # create + make bootable
105
+ yamine worktree list # branch, dir, db, dirty/merged
106
+ yamine worktree remove feature/login
107
+ yamine worktree clean [--dry-run]
108
+ ```
109
+
110
+ `add` creates the git worktree beside the repo, copies the gitignored
111
+ per-checkout config (`config/local.yml`, `config/local.secrets`), runs
112
+ `bundle install`, and pre-creates the per-worktree database — then boot
113
+ with `yamine start` inside it. `clean` tears down every worktree whose
114
+ branch is merged (stops backends, drops the database, removes the
115
+ worktree, deletes the branch) and forgets claims of directories that
116
+ vanished. It never touches uncommitted work; unmerged branches survive
117
+ everything except `remove --force`. Prefer `clean --dry-run` first, and
118
+ `clean` over `rm -rf` — a removed worktree leaves no routes, database,
119
+ or stale hosts entries behind.
120
+
101
121
  ## Subdomains are opt-in
102
122
 
103
123
  A route answers its exact hostname. `*.myapp.localhost` reaches the app
@@ -955,57 +955,6 @@ module Yamine
955
955
  def boot_env_name
956
956
  ENV.fetch("RAILS_ENV", "development")
957
957
  end
958
-
959
- # Worktree helpers: list claimed databases with liveness, clean
960
- # drops databases whose worktree dirs are gone.
961
- def worktree(ctx, args)
962
- sub = args.first
963
- case sub
964
- when "list", nil
965
- map = Database.load_map(ctx.store.dir)
966
- if map.empty?
967
- puts "No worktree databases claimed yet."
968
- return
969
- end
970
- map.each do |name, info|
971
- alive = File.directory?(info["dir"]) ? "alive" : "gone"
972
- puts " #{name} #{info["dir"]} (#{alive})"
973
- end
974
- when "clean"
975
- orphaned = Database.orphaned(ctx.store.dir)
976
- if orphaned.empty?
977
- puts "No orphaned worktree databases."
978
- return
979
- end
980
- template = drop_template
981
- unless template && !template.strip.empty?
982
- $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot drop the orphaned databases."
983
- exit 1
984
- end
985
- dropped, missing, failed = [], [], []
986
- orphaned.each do |name, _info|
987
- case Database.drop(name, template)
988
- when :dropped then dropped << name
989
- when :missing then missing << name
990
- when :failed then failed << name
991
- end
992
- end
993
- dropped.each { |n| puts " dropped #{n}" }
994
- missing.each { |n| puts " #{n} did not exist — nothing to drop" }
995
- failed.each { |n| warn " could not drop #{n} — is the server running? Claim kept for a retry." }
996
- # Forget only the claims we actually handled; a failed drop
997
- # stays recorded so the next clean can retry it.
998
- map = Database.load_map(ctx.store.dir)
999
- (dropped + missing).each { |n| map.delete(n) }
1000
- Database.save_map(ctx.store.dir, map)
1001
- summary = "Cleaned #{orphaned.length} orphaned claim(s): #{dropped.length} dropped, #{missing.length} already gone"
1002
- summary += ", #{failed.length} failed" if failed.any?
1003
- puts "#{summary}."
1004
- exit 1 if failed.any?
1005
- else
1006
- raise Error, "Usage: yamine worktree [list|clean]"
1007
- end
1008
- end
1009
958
  end
1010
959
  end
1011
- end
960
+ end
@@ -0,0 +1,534 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module Yamine
7
+ class CLI
8
+ # Worktree lifecycle. yamine already gives every linked worktree its
9
+ # own URL, database, and routes; these commands let it own the whole
10
+ # span, from `add` (create + make it bootable with no follow-up
11
+ # work) to `clean` (when a branch is merged, take down everything
12
+ # the worktree stood up, including the worktree itself).
13
+ #
14
+ # Safety rails, in order of hardness:
15
+ # * the main checkout is never a candidate (Worktrees filters it);
16
+ # * `clean` never touches a worktree with uncommitted changes;
17
+ # * branches are deleted with `git branch -d`, so git itself
18
+ # refuses to drop unmerged refs — `clean --all` may remove an
19
+ # unmerged worktree but never its branch;
20
+ # * only `worktree remove --force` (git --force + branch -D)
21
+ # overrides, and it names what it discarded;
22
+ # * a database drop that fails aborts the teardown before the
23
+ # directory is touched, keeping the claim for a retry.
24
+ module WorktreeCommand
25
+ module_function
26
+
27
+ # Per-checkout config that git does not carry: a fresh worktree
28
+ # boots without these (missing secrets, no proxy config) — the
29
+ # single largest follow-up cost of a new worktree.
30
+ LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets].freeze
31
+
32
+ def run(ctx, args)
33
+ sub = args.first
34
+ case sub
35
+ when "list", nil then list(ctx, args[1..] || [])
36
+ when "add" then add(ctx, args[1..] || [])
37
+ when "remove" then remove(ctx, args[1..] || [])
38
+ when "clean" then clean(ctx, args[1..] || [])
39
+ else
40
+ raise Error,
41
+ "Usage: yamine worktree list | add <name> [--dir <path>] [--no-install] | " \
42
+ "remove <name> [--force] | clean [--all] [--dry-run]"
43
+ end
44
+ end
45
+
46
+ def list(ctx, _args)
47
+ map = Database.load_map(ctx.store.dir)
48
+ repo = repo_top
49
+ printed = false
50
+
51
+ if repo
52
+ default_branch = Worktrees.default_branch(repo)
53
+ Worktrees.list(repo).each do |entry|
54
+ next if entry.bare
55
+ puts " #{entry_line(entry, claim_for(map, entry.path), repo, default_branch)}"
56
+ printed = true
57
+ end
58
+ end
59
+ map.select { |_, info| !File.directory?(info["dir"]) }.each do |name, info|
60
+ puts " #{name} #{info["dir"]} (gone)"
61
+ printed = true
62
+ end
63
+ puts "No worktrees or database claims." unless printed
64
+ end
65
+
66
+ def entry_line(entry, claim, repo, default_branch)
67
+ name = entry.main? ? "main" : (entry.branch || "(detached)")
68
+ line = "#{name} #{entry.path}"
69
+ line += " db #{claim[0]}" if claim
70
+
71
+ flags = []
72
+ flags << "main checkout" if entry.main?
73
+ flags << "detached" if entry.detached && !entry.main?
74
+ flags << "dir gone" unless entry.exists?
75
+ if !entry.main? && entry.exists?
76
+ flags << "dirty" if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES)
77
+ if entry.detached
78
+ flags << "unmerged"
79
+ else
80
+ merged = Worktrees.merged?(repo, entry.branch, into: default_branch)
81
+ flags << (merged ? "merged" : "unmerged")
82
+ end
83
+ end
84
+ line += " (#{flags.join(", ")})" unless flags.empty?
85
+ line
86
+ end
87
+
88
+ # yamine worktree add <name> [--dir <path>] [--no-install]
89
+ #
90
+ # `name` is the branch (feature/login stays feature/login — the
91
+ # whole branch is the hostname label). The worktree lands beside
92
+ # the repo as <repo>-<label>, receives the gitignored per-checkout
93
+ # config, a bundle install, and its own database with schema, so
94
+ # the next step is just `yamine start` in it.
95
+ def add(ctx, args)
96
+ opts, rest = take_flags(args, value_flags: %w[--dir], flags: %w[--no-install])
97
+ name = rest.first
98
+ unless name
99
+ raise Error, "Usage: yamine worktree add <name> [--dir <path>] [--no-install]"
100
+ end
101
+
102
+ repo = repo_top || abort_not_in_repo
103
+ label = Sanitize.hostname_label(name)
104
+ if label.empty?
105
+ raise Error, "#{name.inspect} has no usable hostname label"
106
+ end
107
+ if Variant::DEFAULT_BRANCHES.include?(name)
108
+ raise Error, "refusing to create a worktree on the default branch #{name.inspect}"
109
+ end
110
+
111
+ dir = opts[:dir] || File.expand_path("../#{File.basename(repo)}-#{label}", repo)
112
+ if File.exist?(dir)
113
+ raise Error, "#{dir} already exists — remove it first or pass --dir"
114
+ end
115
+
116
+ out, status = Worktrees.add(repo, name, dir)
117
+ unless status&.success?
118
+ $stderr.puts "Error: git worktree add failed:"
119
+ $stderr.puts out.to_s.gsub(/^/, " ")
120
+ exit 1
121
+ end
122
+ puts " created #{dir} (branch #{name})"
123
+
124
+ copied = copy_local_config(repo, dir)
125
+ copied.each { |f| puts " copied #{f}" }
126
+ if copied.empty?
127
+ puts " no config/local.yml in #{repo} — run `yamine init` in the worktree before booting"
128
+ end
129
+
130
+ install_deps(dir) unless opts[:no_install]
131
+
132
+ db_name = prepare_database(ctx, dir)
133
+ say_ready(ctx, dir, name, db_name)
134
+ end
135
+
136
+ def copy_local_config(from_dir, to_dir)
137
+ copied = []
138
+ LOCAL_CONFIG_FILES.each do |rel|
139
+ src = File.join(from_dir, rel)
140
+ next unless File.file?(src)
141
+
142
+ dst = File.join(to_dir, rel)
143
+ FileUtils.mkdir_p(File.dirname(dst))
144
+ FileUtils.cp(src, dst)
145
+ copied << rel
146
+ end
147
+ copied
148
+ end
149
+
150
+ # The deps phase would fail-fast on a missing bundle anyway; doing
151
+ # it here turns "add" into "ready to boot". Failure warns and
152
+ # continues — boot's own pre-flight reports it with the fix.
153
+ def install_deps(dir)
154
+ return unless File.file?(File.join(dir, "Gemfile"))
155
+
156
+ puts " bundle install..."
157
+ system("bundle", "install", chdir: dir)
158
+ puts " bundle install did not finish — run it in #{dir} before booting" unless $?.success?
159
+ end
160
+
161
+ # Claim the per-worktree database and create it now (with the
162
+ # app's schema) so server problems surface at creation time, not
163
+ # at first boot. Boot reuses an existing database idempotently, so
164
+ # this is never wasted work. SQLite and template-less apps need
165
+ # nothing; setup_database says so itself.
166
+ def prepare_database(ctx, dir)
167
+ return nil unless Config.load(dir)
168
+
169
+ db_name = Database.name_for(dir, env: BootCommand.rails_env,
170
+ state_dir: ctx.store.dir)
171
+ Dir.chdir(dir) do
172
+ BootCommand.setup_database(ctx, Resolver.resolve(dir), db_name)
173
+ end
174
+ db_name
175
+ end
176
+
177
+ def say_ready(ctx, dir, name, db_name)
178
+ puts "Worktree ready:"
179
+ puts " dir #{dir}"
180
+ puts " branch #{name}"
181
+ puts " db #{db_name}" if db_name
182
+ if (resolved = resolved_quietly(dir))
183
+ Resolver.urls(resolved, port: ctx.proxy_port, tls: ctx.proxy_tls)
184
+ .each { |u| puts " url #{u}" }
185
+ end
186
+ puts " boot it: cd #{dir} && yamine start"
187
+ end
188
+
189
+ def resolved_quietly(dir)
190
+ Resolver.resolve(dir)
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
195
+ # yamine worktree remove <name> [--force]
196
+ #
197
+ # Full teardown of one worktree, merge state regardless (the flag
198
+ # only decides whether git may discard uncommitted changes and
199
+ # delete an unmerged branch). --force is the only path that can
200
+ # do either.
201
+ def remove(ctx, args)
202
+ opts, rest = take_flags(args, value_flags: [], flags: %w[--force])
203
+ name = rest.first
204
+ raise Error, "Usage: yamine worktree remove <name> [--force]" unless name
205
+
206
+ repo = repo_top || abort_not_in_repo
207
+ entry = Worktrees.find(repo, name)
208
+ if entry.nil? || entry.main?
209
+ raise Error, "no worktree named #{name.inspect} here — `yamine worktree list` shows what exists"
210
+ end
211
+
212
+ unless entry.exists?
213
+ abandon(ctx, repo, entry)
214
+ return
215
+ end
216
+
217
+ if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES) && !opts[:force]
218
+ $stderr.puts "Error: #{entry.path} has uncommitted changes."
219
+ $stderr.puts " Commit or stash them, or re-run with --force to discard them."
220
+ exit 1
221
+ end
222
+
223
+ exit 1 unless teardown(ctx, repo, entry, force: opts[:force])
224
+ Worktrees.prune(repo)
225
+ resync_hosts(ctx)
226
+ end
227
+
228
+ # yamine worktree clean [--all] [--dry-run]
229
+ #
230
+ # The done-and-merged sweep: databases of worktree directories
231
+ # that no longer exist (any repo), plus full teardown of this
232
+ # repo's merged worktrees. Unmerged and dirty worktrees are kept,
233
+ # with the reason — they are work in progress, not leftovers.
234
+ def clean(ctx, args)
235
+ opts, rest = take_flags(args, value_flags: [], flags: %w[--all --dry-run])
236
+ unless rest.empty?
237
+ raise Error, "Usage: yamine worktree clean [--all] [--dry-run]"
238
+ end
239
+
240
+ repo = repo_top
241
+ plan = build_plan(ctx, repo, all: opts[:all])
242
+ if plan.empty?
243
+ puts "Nothing to clean."
244
+ return
245
+ end
246
+
247
+ if opts[:dry_run]
248
+ plan.each { |step| puts " would #{step.describe}" }
249
+ return
250
+ end
251
+
252
+ failures = 0
253
+ changed = false
254
+ plan.each do |step|
255
+ case step[0]
256
+ when :drop_db
257
+ if drop_orphan(ctx, step[1], step[2])
258
+ changed = true
259
+ else
260
+ failures += 1
261
+ end
262
+ when :teardown
263
+ if teardown(ctx, repo, step[1], force: false)
264
+ changed = true
265
+ else
266
+ failures += 1
267
+ end
268
+ when :prune
269
+ changed = true
270
+ when :keep
271
+ puts " kept #{step[1]} — #{step[2]}"
272
+ end
273
+ end
274
+ Worktrees.prune(repo) if repo
275
+ resync_hosts(ctx) if repo && changed
276
+ ran = plan.count { |s| %i[drop_db teardown prune].include?(s[0]) }
277
+ summary = "Cleaned #{ran} item(s)"
278
+ summary += ", #{failures} failed" if failures.positive?
279
+ puts "#{summary}."
280
+ exit 1 if failures.positive?
281
+ end
282
+
283
+ Plan = Struct.new(:action, :subject, :reason) do
284
+ def describe
285
+ case action
286
+ when :drop_db then "drop database #{subject} (#{reason} is gone)"
287
+ when :teardown then "remove worktree #{subject.path} (#{reason})"
288
+ when :prune then "prune stale git entry #{subject}"
289
+ when :keep then "keep #{subject} — #{reason}"
290
+ end
291
+ end
292
+ end
293
+
294
+ def build_plan(ctx, repo, all:)
295
+ plan = []
296
+ Database.orphaned(ctx.store.dir).each do |name, info|
297
+ plan << Plan.new(:drop_db, name, info["dir"])
298
+ end
299
+ return plan unless repo
300
+
301
+ default_branch = Worktrees.default_branch(repo)
302
+ Worktrees.list(repo).each do |entry|
303
+ next if entry.bare || entry.main?
304
+
305
+ unless entry.exists?
306
+ plan << Plan.new(:prune, entry.path)
307
+ next
308
+ end
309
+ if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES)
310
+ plan << Plan.new(:keep, entry.path, "uncommitted changes are never cleaned automatically")
311
+ next
312
+ end
313
+ merged = Worktrees.merged?(repo, entry.branch, into: default_branch)
314
+ if merged
315
+ plan << Plan.new(:teardown, entry, "branch merged into #{default_branch}")
316
+ elsif all
317
+ plan << Plan.new(:teardown, entry,
318
+ "--all; branch #{entry.branch} stays (unmerged into #{default_branch})")
319
+ else
320
+ plan << Plan.new(:keep, entry.path,
321
+ "branch not merged into #{default_branch} (--all to override)")
322
+ end
323
+ end
324
+ plan
325
+ end
326
+
327
+ # An admin entry whose directory vanished (rm -rf, a crashed
328
+ # agent): drop the database it claimed, forget the claim. The git
329
+ # entry itself is cleared by the trailing prune. A template with
330
+ # no server behind it (sqlite) means there is nothing to drop and
331
+ # no retry that could help — the claim is simply forgotten.
332
+ def drop_orphan(ctx, name, _dir)
333
+ template = orphan_template(ctx)
334
+ unless template
335
+ $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in " \
336
+ "config/local.yml) — cannot drop #{name}."
337
+ return false
338
+ end
339
+ unless %i[postgres mysql].include?(Database.adapter_for(template))
340
+ puts " #{name} has no server database (sqlite/unknown adapter) — claim forgotten"
341
+ forget_claim(ctx, name)
342
+ return true
343
+ end
344
+ case Database.drop(name, template)
345
+ when :dropped then puts " dropped #{name}"
346
+ when :missing then puts " #{name} did not exist — nothing to drop"
347
+ when :failed
348
+ warn " could not drop #{name} — is the server running? Claim kept for a retry."
349
+ return false
350
+ end
351
+ forget_claim(ctx, name)
352
+ true
353
+ end
354
+
355
+ def forget_claim(ctx, name)
356
+ map = Database.load_map(ctx.store.dir)
357
+ map.delete(name)
358
+ Database.save_map(ctx.store.dir, map)
359
+ end
360
+
361
+ # Orphan directories are gone, so their configs are unreadable —
362
+ # the template comes from the environment or whatever checkout the
363
+ # command runs in, exactly as `db drop` has always done.
364
+ def orphan_template(ctx)
365
+ env = ENV["DATABASE_URL"]
366
+ return env if env && !env.strip.empty?
367
+
368
+ config = Config.load(Dir.pwd)
369
+ config && Database.template_for(config)
370
+ end
371
+
372
+ # A worktree whose directory is already gone: nothing to stop or
373
+ # remove, just its database claim and the stale git entry.
374
+ def abandon(ctx, repo, entry)
375
+ map = Database.load_map(ctx.store.dir)
376
+ claim = claim_for(map, entry.path)
377
+ if claim
378
+ exit 1 unless drop_orphan(ctx, claim[0], entry.path)
379
+ end
380
+ Worktrees.prune(repo)
381
+ puts " pruned #{entry.path} (directory was already gone)"
382
+ end
383
+
384
+ # The teardown shared by `remove` and `clean`. Returns true when
385
+ # the worktree is gone; false means abort with state intact for a
386
+ # retry. The database drop happens BEFORE git removes the
387
+ # directory: a failed drop must not leave a database behind with
388
+ # no config to reach it.
389
+ def teardown(ctx, repo, entry, force:)
390
+ dir = entry.path
391
+ map = Database.load_map(ctx.store.dir)
392
+ claim = claim_for(map, dir)
393
+
394
+ stop_routes(ctx, dir)
395
+
396
+ if claim
397
+ outcome = drop_database(repo, dir, claim[0])
398
+ if outcome == :failed
399
+ warn " kept #{dir} — database drop failed; fix the server and re-run"
400
+ return false
401
+ end
402
+ map = Database.load_map(ctx.store.dir)
403
+ map.delete(claim[0])
404
+ Database.save_map(ctx.store.dir, map)
405
+ end
406
+
407
+ out, status = Worktrees.remove(repo, dir, force: force)
408
+ unless status&.success?
409
+ $stderr.puts "Error: git worktree remove failed:"
410
+ $stderr.puts out.to_s.gsub(/^/, " ")
411
+ return false
412
+ end
413
+ puts " removed #{dir}"
414
+
415
+ if entry.branch
416
+ flag = force ? "-D" : "-d"
417
+ _o, deleted = Open3.capture2("git", "branch", flag, entry.branch, chdir: repo,
418
+ err: File::NULL)
419
+ if deleted&.success?
420
+ puts " deleted branch #{entry.branch}"
421
+ else
422
+ puts " kept branch #{entry.branch} (git refuses to delete an unmerged branch)"
423
+ end
424
+ end
425
+ true
426
+ end
427
+
428
+ # Routes registered from this worktree (spec.dir marks
429
+ # boot-registered backends). The directory is about to disappear,
430
+ # so live backends are stopped regardless of which agent owns
431
+ # them — no route could survive the removal anyway, and leaving a
432
+ # live process with its working directory deleted is worse.
433
+ def stop_routes(ctx, dir)
434
+ routes = ctx.store.load_routes_raw.select do |r|
435
+ r.dig("spec", "dir") == File.expand_path(dir)
436
+ end
437
+ routes.each do |entry|
438
+ hostname = entry["hostname"]
439
+ backend_pid = ctx.backend_pid_for(entry)
440
+ if backend_pid && ProxyControl.pid_alive?(backend_pid)
441
+ begin
442
+ Process.kill("TERM", backend_pid)
443
+ ctx.wait_for_exit(backend_pid, timeout: 10)
444
+ rescue SystemCallError
445
+ nil
446
+ end
447
+ end
448
+ ctx.store.remove_route(hostname)
449
+ FileUtils.rm_f(File.join(ctx.store.dir, "backend-#{hostname}.pid"))
450
+ puts " stopped #{hostname}"
451
+ end
452
+ end
453
+
454
+ def drop_database(repo, dir, name)
455
+ template = drop_template_for(repo, dir)
456
+ adapter = template && Database.adapter_for(template)
457
+ unless %i[postgres mysql].include?(adapter)
458
+ puts " no server database to drop for #{name} " \
459
+ "(#{template ? "sqlite/unknown adapter" : "no DATABASE_URL template"})"
460
+ return :missing
461
+ end
462
+
463
+ outcome = Database.drop(name, template)
464
+ case outcome
465
+ when :dropped then puts " dropped #{name}"
466
+ when :missing then puts " #{name} did not exist — nothing to drop"
467
+ when :failed then puts " could not drop #{name} — is the database server running?"
468
+ end
469
+ outcome
470
+ end
471
+
472
+ # Reach the database server from the worktree's own config (read
473
+ # BEFORE the directory is removed), falling back to the main
474
+ # checkout, then the environment.
475
+ def drop_template_for(repo, dir)
476
+ [dir, repo].each do |base|
477
+ config = Config.load(base) rescue nil
478
+ next unless config
479
+
480
+ template = Database.template_for(config)
481
+ return template if template && !template.strip.empty?
482
+ end
483
+ ENV["DATABASE_URL"]
484
+ end
485
+
486
+ # The removed routes should leave the hosts block too. Best-effort:
487
+ # the file is root-owned, and the next boot's workstation check
488
+ # re-syncs anyway.
489
+ def resync_hosts(ctx)
490
+ ctx.store.prune_stale
491
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
492
+ return if Hosts.sync(hostnames)
493
+
494
+ warn "Warning: could not update /etc/hosts (try sudo yamine hosts sync)."
495
+ end
496
+
497
+ def claim_for(map, dir)
498
+ expanded = File.expand_path(dir)
499
+ map.find { |_, info| info["dir"] == expanded }
500
+ end
501
+
502
+ def repo_top
503
+ out, status = Open3.capture2("git", "rev-parse", "--show-toplevel",
504
+ chdir: Dir.pwd, err: File::NULL)
505
+ status&.success? ? out.strip : nil
506
+ rescue SystemCallError
507
+ nil
508
+ end
509
+
510
+ def abort_not_in_repo
511
+ $stderr.puts "Error: not inside a git repository — run from the app's checkout."
512
+ exit 1
513
+ end
514
+
515
+ def take_flags(args, value_flags:, flags:)
516
+ opts = {}
517
+ rest = []
518
+ until args.empty?
519
+ arg = args.shift
520
+ next rest << arg unless arg.start_with?("--")
521
+
522
+ if value_flags.include?(arg)
523
+ opts[arg.sub(/\A--/, "").tr("-", "_").to_sym] = args.shift
524
+ elsif flags.include?(arg)
525
+ opts[arg.sub(/\A--/, "").tr("-", "_").to_sym] = true
526
+ else
527
+ raise Error, "Unknown flag #{arg}"
528
+ end
529
+ end
530
+ [opts, rest]
531
+ end
532
+ end
533
+ end
534
+ end
data/lib/yamine/cli.rb CHANGED
@@ -60,7 +60,7 @@ module Yamine
60
60
  when "init" then SystemCommand.init(ctx, args)
61
61
  when "start" then SystemCommand.start(ctx, args)
62
62
  when "db" then SystemCommand.db(ctx, args)
63
- when "worktree" then SystemCommand.worktree(ctx, args)
63
+ when "worktree" then WorktreeCommand.run(ctx, args)
64
64
  when "kamal" then SystemCommand.kamal(ctx, args)
65
65
  when "stop"
66
66
  exit RoutesCommand.stop(ctx, args)
@@ -96,7 +96,7 @@ module Yamine
96
96
  yamine clean Remove state and hosts entries
97
97
  yamine prune Remove stale routes
98
98
  yamine db list|create|drop Per-worktree databases
99
- yamine worktree list|clean Worktree databases + orphan cleanup
99
+ yamine worktree list|add|remove|clean Worktrees: create, inspect, full cleanup
100
100
  yamine proxy start|stop Control the proxy
101
101
  yamine service install|status|uninstall OS startup service
102
102
  yamine sudoers Print scoped passwordless-sudo rules for port 443
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.13.2"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Yamine
6
+ # Git worktree plumbing for `yamine worktree add|list|remove|clean`.
7
+ #
8
+ # Thin wrappers over the git CLI: failures carry git's own stderr so
9
+ # the user sees git's reason, not a paraphrase. Every caller filters
10
+ # out the main checkout (Entry#main?) — cleanup commands must never
11
+ # be able to touch it, so the filter lives in the data, not in
12
+ # whoever remembers to check.
13
+ module Worktrees
14
+ module_function
15
+
16
+ Entry = Struct.new(:path, :head, :branch, :detached, :bare, keyword_init: true) do
17
+ def main?
18
+ Database.main_checkout?(path)
19
+ end
20
+
21
+ def exists?
22
+ File.directory?(path)
23
+ end
24
+ end
25
+
26
+ # All worktrees of the repo `dir` belongs to, main checkout first
27
+ # (git always lists it first). Empty when `dir` is not a repo.
28
+ def list(dir = Dir.pwd)
29
+ out, status = git(dir, "worktree", "list", "--porcelain")
30
+ return [] unless status&.success?
31
+
32
+ parse(out)
33
+ end
34
+
35
+ def parse(porcelain)
36
+ entries = []
37
+ entry = nil
38
+ porcelain.lines.each do |line|
39
+ key, value = line.strip.split(" ", 2)
40
+ case key
41
+ when "worktree"
42
+ entries << entry if entry
43
+ entry = Entry.new(path: File.expand_path(value.to_s), head: nil,
44
+ branch: nil, detached: false, bare: false)
45
+ when "HEAD" then entry&.head = value
46
+ when "branch" then entry&.branch = value.to_s.sub(%r{\Arefs/heads/}, "")
47
+ when "detached" then entry&.detached = true
48
+ when "bare" then entry&.bare = true
49
+ end
50
+ end
51
+ entries << entry if entry
52
+ entries
53
+ end
54
+
55
+ # The worktree whose branch or directory basename is `name`.
56
+ def find(dir, name)
57
+ list(dir).find do |entry|
58
+ next false if entry.bare
59
+
60
+ entry.branch == name || File.basename(entry.path) == name
61
+ end
62
+ end
63
+
64
+ # Create a worktree on `branch`: checked out when the branch already
65
+ # exists, created otherwise. Returns [stderr, status]; stderr is
66
+ # user-facing on failure.
67
+ def add(base_dir, branch, path)
68
+ if branch_exists?(base_dir, branch)
69
+ run(base_dir, "worktree", "add", path, branch)
70
+ else
71
+ run(base_dir, "worktree", "add", "-b", branch, path)
72
+ end
73
+ end
74
+
75
+ def remove(base_dir, path, force: false)
76
+ args = ["worktree", "remove"]
77
+ args << "--force" if force
78
+ run(base_dir, *args, path)
79
+ end
80
+
81
+ # Forget worktree admin entries whose directories are gone.
82
+ def prune(base_dir)
83
+ run(base_dir, "worktree", "prune")
84
+ end
85
+
86
+ def branch_exists?(base_dir, branch)
87
+ _out, status = git(base_dir, "rev-parse", "--verify", "--quiet",
88
+ "refs/heads/#{branch}")
89
+ status&.success?
90
+ end
91
+
92
+ # Uncommitted changes at `path`, disregarding `ignore` paths. The
93
+ # per-checkout config yamine copies (config/local.yml, local.secrets)
94
+ # is supposed to be present and is usually gitignored anyway —
95
+ # without the ignore list a freshly-added worktree reads as dirty
96
+ # and no cleanup could ever take it down without --force.
97
+ def dirty?(path, ignore: [])
98
+ out, status = git(path, "status", "--porcelain")
99
+ return false unless status&.success?
100
+
101
+ # Porcelain is column-based: XY then a space, path at index 3 —
102
+ # XY itself is often " M" or "??", so the path must be cut by
103
+ # position, never by stripping whitespace first.
104
+ out.lines.any? do |line|
105
+ next false if line.strip.empty?
106
+
107
+ !ignore.include?(line[3..].to_s.strip)
108
+ end
109
+ end
110
+
111
+ # Is `branch` fully merged into `into`? Anything git cannot answer
112
+ # is reported unmerged, so deletion stays the explicit choice.
113
+ def merged?(base_dir, branch, into:)
114
+ return false unless branch && into
115
+
116
+ _out, status = git(base_dir, "merge-base", "--is-ancestor", branch, into)
117
+ status&.success?
118
+ end
119
+
120
+ # The branch cleanup merges against: a local main or master when one
121
+ # exists, else whatever origin/HEAD points at, else master.
122
+ def default_branch(base_dir)
123
+ Variant::DEFAULT_BRANCHES.each do |candidate|
124
+ return candidate if branch_exists?(base_dir, candidate)
125
+ end
126
+
127
+ out, status = git(base_dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
128
+ return out.strip.sub(%r{\Arefs/remotes/origin/}, "") if status&.success? && !out.strip.empty?
129
+
130
+ "master"
131
+ end
132
+
133
+ # Capture stderr: for add/remove, git's message IS the error.
134
+ def run(dir, *args)
135
+ out, status = Open3.capture2e("git", *args, chdir: dir)
136
+ [out, status]
137
+ rescue SystemCallError, ArgumentError => e
138
+ [e.message, nil]
139
+ end
140
+
141
+ def git(dir, *args)
142
+ Open3.capture2("git", *args, chdir: dir, err: File::NULL)
143
+ rescue SystemCallError, ArgumentError
144
+ ["", nil]
145
+ end
146
+ end
147
+ end
data/lib/yamine.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "yamine/sanitize"
8
8
  require_relative "yamine/hostname"
9
9
  require_relative "yamine/inference"
10
10
  require_relative "yamine/variant"
11
+ require_relative "yamine/worktrees"
11
12
  require_relative "yamine/framework"
12
13
  require_relative "yamine/config"
13
14
  require_relative "yamine/ownership"
@@ -30,6 +31,7 @@ require_relative "yamine/procfile"
30
31
  require_relative "yamine/cli/boot"
31
32
  require_relative "yamine/cli/routes"
32
33
  require_relative "yamine/cli/system"
34
+ require_relative "yamine/cli/worktree"
33
35
  require_relative "yamine/cli"
34
36
 
35
37
  # Stable named .localhost URLs for Ruby development.
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.13.2
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -89,6 +89,7 @@ files:
89
89
  - lib/yamine/cli/context.rb
90
90
  - lib/yamine/cli/routes.rb
91
91
  - lib/yamine/cli/system.rb
92
+ - lib/yamine/cli/worktree.rb
92
93
  - lib/yamine/command.rb
93
94
  - lib/yamine/config.rb
94
95
  - lib/yamine/database.rb
@@ -113,6 +114,7 @@ files:
113
114
  - lib/yamine/trust.rb
114
115
  - lib/yamine/variant.rb
115
116
  - lib/yamine/version.rb
117
+ - lib/yamine/worktrees.rb
116
118
  homepage: https://github.com/ask-rb/yamine
117
119
  licenses:
118
120
  - MIT