yamine 0.13.1 → 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: 43ea000326072db8f0173360b3ef58a6cce132e20f372b65f0e777bafbb97e9d
4
- data.tar.gz: 3e878d2cae8afffd1dca255a006a5863e12dbd4471a4b6214656dda5bc97d37f
3
+ metadata.gz: ab7c7e888dc8066ad10a7ed2fa90dcd684b27aa171038ac0ae5ea4c06b22251b
4
+ data.tar.gz: '097d12693a71cf3969f481fd0f61b3f82e9a09d7958139e32b3fb79a7e667cd8'
5
5
  SHA512:
6
- metadata.gz: 272dae53dfac6b0521c09c00ae096cdc10620dcabf8064a286a0fe25813a61738b3fefc4bb34cf8fdfdd8dd1467a0064ec4e8ceb6ec7b262a8ed638b66101bda
7
- data.tar.gz: e16540d1830850c50a54451ec5f0bfb0ad87fd770b111a0b4276aad9a6916dadeeed4c106b61392e5fc5794e6276f003f718a5e53135885f36cc142944d1a12f
6
+ metadata.gz: 99ddf961f8fa7e50038cb093b592f692c4c90a94279dc200b9ab7ea9e8a70355e6bf67659bf0cb53aac66d2868d99c6fc138750e490aa1fa4ff5b606bbfacc3f
7
+ data.tar.gz: 825fb69b730125767e18472ca3d7d615514660b63dbcb7aa63697970f6733b1497997b505a6fde9f1e6b0c62785127fac0d2b1332544ff4d535b4076680623b8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
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
+
40
+ ## [0.13.2] — 2026-09-12
41
+
42
+ ### Fixed
43
+
44
+ - **"Resolves" now means "resolves for every client", not "the system
45
+ resolver said yes".** `Hosts.resolves?` asked `Addrinfo.getaddrinfo`,
46
+ which on macOS answers any `.localhost` name unconditionally — so
47
+ doctor and boot reported every `.localhost` route as fully resolved
48
+ even with no `/etc/hosts` entry, while a client that resolves only
49
+ from the hosts file (a CGO-disabled Go binary; AnyGit is the case
50
+ that found it) could not resolve any of them. That invisible gap is
51
+ why `sudo yamine hosts sync` existed as a manual step at all.
52
+
53
+ The check is now state-based. `Hosts.hosts_entry?` reads the hosts
54
+ file directly — any loopback mapping counts, hand-added entries
55
+ included, not just yamine's managed block — and `Hosts.resolution`
56
+ classifies each route as `ok` (every client resolves it), `warn`
57
+ (browsers and curl fine via the RFC 6761 `.localhost` special case;
58
+ file-only resolvers cannot see it), or `fail` (nothing resolves it).
59
+ Doctor renders the warn class with its existing `[warn]` state instead
60
+ of a failure — the 0.7.0 lesson, kept by severity and wording rather
61
+ than by a probe — and boot names the two gaps separately.
62
+
63
+ `.localhost` names are classified on file absence alone: the system
64
+ resolver's unconditional yes carries no information, and acting on it
65
+ was the bug. getaddrinfo now runs only for custom-TLD names the file
66
+ does not answer (real DNS or `/etc/resolver` setups stay `ok`), so
67
+ the common doctor path does no probing at all.
68
+
3
69
  ## [0.13.1] — 2026-09-12
4
70
 
5
71
  ### Fixed
data/README.md CHANGED
@@ -122,7 +122,9 @@ destination pattern). Naming a variant also prefixes the hostname — see
122
122
  below for how that differs from a worktree's automatic prefix.
123
123
 
124
124
  `.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
125
- no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
125
+ no DNS server, no `/etc/resolver`. Safari, custom TLDs, and resolvers
126
+ that read only `/etc/hosts` (CGO-disabled Go binaries are the common
127
+ case) need `yamine hosts sync`.
126
128
 
127
129
  ## Hostname shape
128
130
 
@@ -156,6 +158,32 @@ A variant is a hostname label, not a config file. Only an explicit
156
158
  branch named like a file on disk cannot change your config by accident.
157
159
  `yamine status` prints both lines so the two are never confused.
158
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
+
159
187
  ```bash
160
188
  yamine # -> https://myapp.localhost
161
189
  yamine --service api # -> https://api.myapp.localhost
@@ -200,7 +228,7 @@ yamine trust # add local CA to system trust store
200
228
  yamine clean # remove state and hosts entries
201
229
  yamine prune # remove stale routes
202
230
  yamine db list|create|drop # per-worktree databases
203
- yamine worktree list|clean # worktree databases + orphan cleanup
231
+ yamine worktree list|add|remove|clean # worktree lifecycle
204
232
  yamine stop # stop this app's backend + routes
205
233
  yamine restart # touch tmp/restart.txt (managed apps reboot)
206
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
@@ -144,8 +164,10 @@ yamine prune # clear stale routes from crashed sessions
144
164
  yamine start --json # boot readiness payload (pass/fail + log tail)
145
165
  ```
146
166
 
147
- If a hostname does not resolve: `yamine hosts sync`. If the browser
148
- warns about TLS: `yamine trust`.
167
+ If a hostname does not resolve — or boot/doctor report it as not in
168
+ `/etc/hosts` and you use clients that read only that file (CGO-disabled
169
+ Go binaries): `yamine hosts sync`. If the browser warns about TLS:
170
+ `yamine trust`.
149
171
 
150
172
  ## When NOT to use yamine
151
173
 
@@ -157,7 +157,7 @@ module Yamine
157
157
  end
158
158
 
159
159
  say opts
160
- ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
160
+ ctx.report_resolution_gaps(routes_registered.flat_map { |r| r[:hostnames] })
161
161
 
162
162
  # Supervisor: exit when ANY child dies (loud cleanup). pid => name
163
163
  # so the message names the casualty instead of "a process".
@@ -35,11 +35,22 @@ module Yamine
35
35
  ProxyControl.proxy_tls(store)
36
36
  end
37
37
 
38
- def report_unresolved(hostnames)
39
- missing = Hosts.unresolved(hostnames)
40
- return if missing.empty?
38
+ # Boot-time DNS report. Two distinct gaps, named separately: a fail
39
+ # resolves nowhere; a warn resolves in browsers and curl (the RFC
40
+ # 6761 .localhost special case) but not for clients that read only
41
+ # /etc/hosts — which is exactly the gap `hosts sync` closes.
42
+ def report_resolution_gaps(hostnames)
43
+ groups = Hosts.resolution(hostnames)
44
+ return if groups[:fail].empty? && groups[:warn].empty?
41
45
 
42
- warn "Warning: #{missing.join(", ")} will not resolve. Run: yamine hosts sync"
46
+ unless groups[:fail].empty?
47
+ warn "Warning: #{groups[:fail].join(", ")} will not resolve. Run: yamine hosts sync"
48
+ end
49
+ unless groups[:warn].empty?
50
+ warn "Warning: #{groups[:warn].join(", ")} not in /etc/hosts — browsers and curl " \
51
+ "resolve them anyway, but clients that only read /etc/hosts (CGO-disabled Go " \
52
+ "binaries) cannot. Run: yamine hosts sync if you use such clients."
53
+ end
43
54
  end
44
55
 
45
56
  def wait_for_exit(pid, timeout:)
@@ -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
data/lib/yamine/doctor.rb CHANGED
@@ -160,12 +160,24 @@ module Yamine
160
160
  hostnames = store.load_routes.map { |r| r["hostname"] }
161
161
  return Check.new(name: "dns", ok: true, message: "no routes to resolve") if hostnames.empty?
162
162
 
163
- missing = Hosts.unresolved(hostnames)
164
- if missing.empty?
163
+ groups = Hosts.resolution(hostnames)
164
+ problems = []
165
+ problems << "#{groups[:fail].join(", ")} do not resolve — run: yamine hosts sync" unless groups[:fail].empty?
166
+ unless groups[:warn].empty?
167
+ problems << "#{groups[:warn].join(", ")} not in /etc/hosts — browsers and curl " \
168
+ "resolve them anyway, but clients that only read the hosts file " \
169
+ "(CGO-disabled Go binaries) cannot — run: yamine hosts sync if you use such clients"
170
+ end
171
+
172
+ if problems.empty?
165
173
  Check.new(name: "dns", ok: true, message: "all #{hostnames.length} hostname(s) resolve")
174
+ elsif groups[:fail].empty?
175
+ # Only the file-only-resolver gap: most clients are fine, so this
176
+ # is a warn, not a failure — failing here sent browser-only users
177
+ # to an elevated hosts write they did not need (the 0.7.0 lesson).
178
+ Check.new(name: "dns", ok: true, warn: true, message: problems.join("; "))
166
179
  else
167
- Check.new(name: "dns", ok: false,
168
- message: "#{missing.join(", ")} do not resolve — run: yamine hosts sync")
180
+ Check.new(name: "dns", ok: false, message: problems.join("; "))
169
181
  end
170
182
  end
171
183
 
data/lib/yamine/hosts.rb CHANGED
@@ -4,9 +4,11 @@ require "socket"
4
4
  require "timeout"
5
5
 
6
6
  module Yamine
7
- # /etc/hosts sync for Safari + custom TLDs (.localhost resolves natively
8
- # in Chrome/Firefox/Edge; Safari uses the system resolver).
9
- # Same managed-block approach as portless hosts.ts.
7
+ # /etc/hosts sync for the client classes that cannot resolve the routes
8
+ # on their own: custom TLDs, and resolvers that read only the hosts file
9
+ # (a CGO-disabled Go binary is the common case — no system resolver, no
10
+ # RFC 6761 special-casing). Same managed-block approach as portless
11
+ # hosts.ts.
10
12
  module Hosts
11
13
  BEGIN_MARKER = "# --- yamine begin ---"
12
14
  END_MARKER = "# --- yamine end ---"
@@ -44,14 +46,13 @@ module Yamine
44
46
  # Bring the managed block to exactly these hostnames. Idempotent: when
45
47
  # the block already matches, this is a no-op returning true.
46
48
  #
47
- # The short-circuit is load-bearing, not an optimization. /etc/hosts is
49
+ # The sync short-circuit is load-bearing, not an optimization. /etc/hosts is
48
50
  # root-owned on a normal machine, so a rewrite fails without sudo — and
49
51
  # `setup` and every boot's workstation check run this. Without the
50
52
  # guard, a machine whose hosts file was already correct got a
51
53
  # "could not write /etc/hosts (try sudo yamine hosts sync)" warning on
52
54
  # every run, sending people to an elevated write for a file that
53
- # needed nothing. (Chrome/Firefox/Edge resolve *.localhost natively;
54
- # /etc/hosts only matters for Safari and custom TLDs.)
55
+ # needed nothing.
55
56
  def sync(hostnames, path = PATH)
56
57
  return true if synced?(hostnames, path)
57
58
 
@@ -85,26 +86,60 @@ module Yamine
85
86
  false
86
87
  end
87
88
 
88
- # Does the hostname resolve for the programs that will actually use
89
- # it — browsers, curl, the app's own HTTP clients? That question can
90
- # only be answered by the SYSTEM resolver (getaddrinfo), which reads
91
- # nsswitch (so /etc/hosts counts) and implements the RFC 6761
92
- # special-use TLDs.
89
+ # Loopback mappings that count as "this name reaches the proxy".
90
+ LOOPBACK = %w[127.0.0.1 ::1].freeze
91
+
92
+ # Does the hosts file map this hostname to loopback? That is the one
93
+ # resolution source every client shares — browsers and curl, the
94
+ # system resolver, and resolvers that read only the file (a
95
+ # CGO-disabled Go binary is the common case). The whole file is
96
+ # searched, not just the managed block, so a hand-added entry counts;
97
+ # only loopback mappings count, since the proxy serves on loopback
98
+ # and any other address points the name away from yamine.
99
+ def hosts_entry?(hostname, path = PATH)
100
+ target = hostname.to_s.downcase
101
+ File.foreach(path) do |line|
102
+ parts = line.split("#", 2).first.to_s.split
103
+ next unless parts.length > 1 && LOOPBACK.include?(parts.first.downcase)
104
+ return true if parts[1..].any? { |name| name.downcase == target }
105
+ end
106
+ false
107
+ rescue SystemCallError
108
+ false
109
+ end
110
+
111
+ # Where one hostname stands for the clients that will actually use
112
+ # it — a question with three answers no single resolver probe can
113
+ # produce:
114
+ #
115
+ # :ok — every client resolves it: mapped in the hosts file, or
116
+ # found by the system resolver without it (real DNS or an
117
+ # /etc/resolver entry for a custom TLD).
118
+ # :warn — browsers and curl handle it (the RFC 6761 .localhost
119
+ # special case), but file-only resolvers cannot see it.
120
+ # :fail — nothing resolves it.
93
121
  #
94
- # Ruby's Resolv is a pure-Ruby DNS client: it never sees /etc/hosts
95
- # and knows nothing about RFC 6761, so it reports "no address for
96
- # myapp.localhost" on a machine where .localhost resolves perfectly.
97
- # That false negative sent users to `sudo yamine hosts sync` — an
98
- # elevated write to /etc/hosts — to fix a problem they did not have.
99
- def resolves?(hostname)
122
+ # .localhost names are classified :warn on file absence alone. The
123
+ # system resolver answers any .localhost unconditionally, so probing
124
+ # it carries no information — and acting on that probe is how doctor
125
+ # and boot once reported every .localhost as fully resolved while a
126
+ # CGO-disabled Go binary could not resolve any of them.
127
+ def classification(hostname, path = PATH)
128
+ return :ok if hosts_entry?(hostname, path)
129
+ return :warn if hostname.to_s.downcase.end_with?(".localhost")
130
+
100
131
  Timeout.timeout(2) { Addrinfo.getaddrinfo(hostname, nil) }
101
- true
132
+ :ok
102
133
  rescue SocketError, SystemCallError, Timeout::Error
103
- false
134
+ :fail
104
135
  end
105
136
 
106
- def unresolved(hostnames)
107
- hostnames.reject { |h| resolves?(h) }
137
+ # Partition hostnames into { ok:, warn:, fail: } for the consumers
138
+ # that report one line per state (doctor, boot).
139
+ def resolution(hostnames, path = PATH)
140
+ groups = { ok: [], warn: [], fail: [] }
141
+ hostnames.each { |h| groups[classification(h, path)] << h }
142
+ groups
108
143
  end
109
144
  end
110
145
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.13.1"
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.1
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