yamine 0.11.1 → 0.12.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: e48f66da1b02dd9ea589c1a30225d6b1f9dc46cd1abd92ffad90c27422b6d4ca
4
- data.tar.gz: '0209cb0ce1c976c5f6a934ba0a74283f3b8064e2fc9aed493208c74077dd5a62'
3
+ metadata.gz: 4a9a67fc55d6a6514571970b0a4ee40a682878c28b4a2bdf3a140a7a509715c8
4
+ data.tar.gz: e3332534d4123efbc4fea82c06c0af753866d4ca48db335860e921121baf2e12
5
5
  SHA512:
6
- metadata.gz: 2626fc9696988148d9637ffd1ce14adc7d7d656b03d61854296ff3b6c818fd82ff840fce5904395ac8d2b54c027c9f7e63f47f0f077609f896542af06c282d28
7
- data.tar.gz: 110d265a913ee76a3ff40328c40cfdc0475e195c3e1518a490294dd2bb7bca0536edbaeedba7bc37815625aa3975880efdb1b1a66252b3c2a9ed16d6a2b5abd1
6
+ metadata.gz: 7306ce065ecb6ff40ee10465f3fffc5108d6786a596c2783194459ec143e42649026f7b14ba170e3bbc9561d14738a613b0da8654ed59a335ba2e54e818a6345
7
+ data.tar.gz: fb1603c3b379c8067ce51a9aec0baf51a669aec3a9a74002493d9d750e7d224ebb9c8feeb46d786a84f1c0f035d590960e4943feb56e0fb9da7c3c8da1b1b186
data/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] — 2026-09-11
4
+
5
+ ### Fixed
6
+
7
+ - **Worktrees get their own URL, as the README always claimed.**
8
+ `Variant.resolve`, the worktree detector, `Hostname.compose`'s variant
9
+ axis and their unit tests were all present, correct, and never
10
+ assembled: `Resolver.resolve` never called `Variant.resolve`, and
11
+ `hostname_for` built `{service}.{app}.{tld}` by hand, bypassing the
12
+ composer. So every worktree of a project answered on the *same*
13
+ hostname as its main checkout, and the ownership gate refused to boot
14
+ the second one — while telling the user to "work in your own worktree
15
+ (each branch gets its own URL)", advice that could not work.
16
+
17
+ A linked worktree is now reachable at `<branch>.<app>.<tld>`
18
+ (`work/next` → `work-next.myapp.localhost`) and boots beside its main
19
+ checkout, which keeps the bare name. Cross-checked by serving two
20
+ branches at once: distinct ports, distinct asset digests, no shared
21
+ route.
22
+
23
+ Found while trying to run a second checkout of a Rails app for UI work,
24
+ which is exactly the case the feature existed for.
25
+
26
+ ### Changed
27
+
28
+ - **The variant axis is split in two: `overlay` and `variant`.** They
29
+ were one setting, which meant that as soon as a worktree supplied its
30
+ own variant, `config/local.<branch>.yml` became an implied file lookup
31
+ — so a branch named like a file on disk would silently change the
32
+ config. Now only an explicit `--variant` / `YAMINE_VARIANT` sets the
33
+ overlay to merge; a worktree branch supplies the hostname label and
34
+ nothing else. `status` prints both lines, so the two can no longer be
35
+ mistaken for each other.
36
+
37
+ - **A branch label is the whole branch, not its last path segment.**
38
+ `feature/login` and `bugfix/login` are different worktrees and used to
39
+ collapse to the same `login` hostname. Overlong branches still resolve
40
+ through the existing hash-suffix truncation.
41
+
42
+ - A worktree on a detached HEAD falls back to its directory name (the
43
+ same identity the per-worktree database uses) instead of returning no
44
+ prefix and quietly colliding with the main checkout.
45
+
46
+ - `yamine status` now honors `--variant` / `--tld`, and accepts
47
+ `--branch`, which the README documented but `parse_flags` rejected as
48
+ an unknown flag.
49
+
50
+ - `proxy.host` is never rewritten by a variant: an explicitly written
51
+ hostname is left exactly as written.
52
+
3
53
  ## [0.11.1] — 2026-09-11
4
54
 
5
55
  ### Fixed
data/README.md CHANGED
@@ -117,7 +117,8 @@ whole tree, and cleans up when one exits.
117
117
 
118
118
  Variants are file overlays: `config/local.<variant>.yml` deep-merges on
119
119
  top of `config/local.yml`, selected by `YAMINE_VARIANT` (Kamal's
120
- destination pattern).
120
+ destination pattern). Naming a variant also prefixes the hostname — see
121
+ below for how that differs from a worktree's automatic prefix.
121
122
 
122
123
  `.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
123
124
  no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
@@ -135,10 +136,24 @@ no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
135
136
  | variant | `fix-ui.myapp` | `--variant`, `YAMINE_VARIANT`, linked worktree branch |
136
137
  | tld | `myapp.preview.example.com` | `--tld` (default `localhost`) |
137
138
 
139
+ `proxy.host` is the one exception: an explicitly written full hostname
140
+ bypasses composition entirely, variant included.
141
+
138
142
  Linked git worktrees get a branch prefix automatically
139
- (`fix-ui.myapp.localhost`); the main checkout keeps the bare name.
140
- Pass `--branch` (or `YAMINE_BRANCH=1`) to prefix by current branch
141
- outside worktrees. `main`/`master`/detached HEAD never prefix.
143
+ (`ui-onboarding.myapp.localhost`); the main checkout keeps the bare name,
144
+ so a worktree and its main checkout run side by side. The label is the
145
+ whole branch (`feature/login` → `feature-login.myapp.localhost`), so two
146
+ branches that share a last segment never share a hostname. A detached
147
+ HEAD has no branch to name it and falls back to the worktree's directory
148
+ — the same identity its per-worktree database uses. `main`/`master`
149
+ never prefix. Pass `--branch` (or `YAMINE_BRANCH=1`) to prefix by the
150
+ current branch outside worktrees.
151
+
152
+ A variant is a hostname label, not a config file. Only an explicit
153
+ `--variant` / `YAMINE_VARIANT` goes looking for
154
+ `config/local.<name>.yml` to merge; a worktree branch never does, so a
155
+ branch named like a file on disk cannot change your config by accident.
156
+ `yamine status` prints both lines so the two are never confused.
142
157
 
143
158
  ```bash
144
159
  yamine # -> https://myapp.localhost
@@ -147,6 +162,11 @@ yamine --variant demo # -> https://demo.myapp.localhos
147
162
  yamine --tld preview.example.com # your own domain (OAuth parity)
148
163
  ```
149
164
 
165
+ Because a registered route answers `*.` subdomains of itself, any
166
+ `<label>.myapp.localhost` reaches the main checkout until a worktree
167
+ registers that exact name — at which point the exact route wins and the
168
+ worktree takes over.
169
+
150
170
  ## Commands
151
171
 
152
172
  ```bash
@@ -78,16 +78,26 @@ yamine get backend --variant demo
78
78
  Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
79
79
  targets. Do not guess ports.
80
80
 
81
- ## Variants are files
81
+ ## Variants are files; worktrees are not
82
82
 
83
83
  A variant is a file overlay, Kamal-style: `config/local.<variant>.yml`
84
- deep-merges over `config/local.yml`. Select it with `YAMINE_VARIANT`
85
- or `--variant`. Worktrees get a branch prefix automatically.
84
+ deep-merges over `config/local.yml`, selected with `YAMINE_VARIANT` or
85
+ `--variant`. Naming one also prefixes the hostname.
86
+
87
+ A git worktree needs none of that: it gets a branch prefix automatically
88
+ (`ui-onboarding.myapp.localhost`) and boots alongside its main checkout,
89
+ which keeps the bare name. The whole branch is the label, so
90
+ `feature/login` and `bugfix/login` stay distinct. A detached worktree
91
+ falls back to its directory name.
86
92
 
87
93
  ```bash
88
94
  YAMINE_VARIANT=fix-ui yamine # boots with config/local.fix-ui.yml merged
89
95
  ```
90
96
 
97
+ Only an explicit variant ever looks for an overlay file — a branch name
98
+ that happens to match one on disk is ignored. `yamine status` shows
99
+ `variant:` and `overlay:` separately.
100
+
91
101
  ## First time on a machine
92
102
 
93
103
  Run `yamine start` — it does the one-shot CA trust, port 443, and
@@ -16,8 +16,9 @@ module Yamine
16
16
  # boots every process, supervises the tree, cleans up on exit.
17
17
  def run_inferred(ctx, args)
18
18
  variant = ENV["YAMINE_VARIANT"]
19
- opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json])
20
- resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
19
+ opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json branch])
20
+ resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld],
21
+ use_branch: opts[:branch])
21
22
  # Ownership gate before any side effects: no proxy spawn, no
22
23
  # port allocation when we'd refuse anyway.
23
24
  check_worktree_ownership!(ctx, resolved, force: opts[:force])
@@ -653,10 +654,10 @@ module Yamine
653
654
  end
654
655
  end
655
656
 
656
- def resolve!(ctx, variant: nil, tld: nil)
657
+ def resolve!(ctx, variant: nil, tld: nil, use_branch: false)
657
658
  # The resolver calls Config.load, which raises ConfigError if
658
659
  # config/local.yml is missing — exactly what we want.
659
- Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
660
+ Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld, use_branch: use_branch)
660
661
  end
661
662
 
662
663
  # json: keeps stdout free for the machine-readable stream — this runs
@@ -175,6 +175,9 @@ module Yamine
175
175
  # agent's cleanup). --force overrides and names the previous owner.
176
176
  def stop(ctx, args, out: $stdout)
177
177
  force = args.delete("--force")
178
+ # In a worktree this resolves that worktree's own hostname (the
179
+ # branch is the variant), so `stop` here never reaches across
180
+ # into the main checkout's running app.
178
181
  resolved = Resolver.resolve(Dir.pwd)
179
182
  hostnames = Resolver.hostnames(resolved)
180
183
  mine = Agent.name
@@ -261,9 +264,14 @@ module Yamine
261
264
  # `yamine` would boot here and why. Answers "why did I get
262
265
  # this URL" without booting anything. --json emits stable keys
263
266
  # for agents instead of prose.
264
- def status(_ctx, args)
267
+ def status(ctx, args)
265
268
  json = args.delete("--json")
266
- resolved = Resolver.resolve(Dir.pwd)
269
+ # The flags exist so `status --variant x` answers the same
270
+ # question boot would; in a worktree the branch supplies the
271
+ # variant anyway, so plain `status` is already the right answer.
272
+ opts = ctx.parse_flags(args, %i[variant tld branch])
273
+ resolved = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld],
274
+ use_branch: opts[:branch])
267
275
  hostnames = Resolver.hostnames(resolved)
268
276
  urls = hostnames.map do |h|
269
277
  Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
@@ -273,6 +281,7 @@ module Yamine
273
281
  tld: resolved.tld, tld_source: resolved.sources[:tld],
274
282
  host: resolved.host, host_source: resolved.sources[:host],
275
283
  variant: resolved.variant, variant_source: resolved.sources[:variant],
284
+ overlay: resolved.overlay, overlay_source: resolved.sources[:overlay],
276
285
  urls: urls,
277
286
  processes: resolved.processes.keys,
278
287
  framework: Framework.detect(Dir.pwd).to_s
@@ -288,7 +297,11 @@ module Yamine
288
297
  else
289
298
  puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
290
299
  end
291
- puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no overlay file, flag, or env"})"
300
+ puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no flag, env, or worktree branch"})"
301
+ # The overlay is a different thing from the variant and only an
302
+ # explicit --variant / YAMINE_VARIANT ever sets it, so say so
303
+ # rather than letting the two look like one setting.
304
+ puts "overlay: #{payload[:overlay] ? "#{payload[:overlay]} (merged)" : "(none)"}"
292
305
  puts "processes: #{payload[:processes].join(", ")}"
293
306
  puts "urls:"
294
307
  urls.each { |u| puts " #{u}" }
@@ -9,10 +9,26 @@ module Yamine
9
9
  module_function
10
10
 
11
11
  Result = Struct.new(:app, :tld, :host, :processes, :secrets,
12
- :sources, :variant, :db, :env, keyword_init: true)
12
+ :sources, :variant, :overlay, :db, :env, keyword_init: true)
13
13
 
14
- def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil)
15
- config = Config.load(dir, variant: variant)
14
+ # Two axes, and keeping them apart is the whole point:
15
+ #
16
+ # overlay — an explicit `config/local.<name>.yml` to deep-merge.
17
+ # Only --variant / YAMINE_VARIANT ever set it, so a
18
+ # branch name can never select config by surprise.
19
+ # variant — the leading hostname label, from that same explicit
20
+ # name OR a linked worktree's branch. It is a name, not
21
+ # a file: a worktree is reachable at
22
+ # <branch>.<app>.localhost without inventing config.
23
+ #
24
+ # They share a value when the user asks for one explicitly. Only
25
+ # then does a variant mean both "merge this file" and "prefix this
26
+ # hostname", which is the documented `--variant` behavior.
27
+ def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil, use_branch: false)
28
+ overlay = (variant || ENV["YAMINE_VARIANT"])&.strip
29
+ overlay = nil if overlay&.empty?
30
+
31
+ config = Config.load(dir, variant: overlay)
16
32
  unless config
17
33
  raise ConfigError, Config.missing_message(dir)
18
34
  end
@@ -21,8 +37,7 @@ module Yamine
21
37
  proxy = config.proxy_config
22
38
  processes = config.processes
23
39
 
24
- variant_name = (variant || ENV["YAMINE_VARIANT"])&.strip
25
- variant_name = nil if variant_name&.empty?
40
+ variant_name, variant_source = Variant.resolve(dir, explicit: variant, use_branch: use_branch)
26
41
 
27
42
  # host or tld: config proxy.host wins over proxy.tld; flags override.
28
43
  if host || proxy["host"]
@@ -41,7 +56,8 @@ module Yamine
41
56
  app: config.path.to_s,
42
57
  tld: proxy["tld"] ? "#{config.path} proxy.tld" : "default (localhost)",
43
58
  host: proxy["host"] ? "#{config.path} proxy.host" : nil,
44
- variant: variant_name ? "config/local.#{variant_name}.yml" : nil
59
+ variant: variant_source,
60
+ overlay: overlay ? "config/local.#{overlay}.yml" : nil
45
61
  }
46
62
 
47
63
  Result.new(
@@ -52,20 +68,33 @@ module Yamine
52
68
  secrets: config.secrets,
53
69
  sources: sources,
54
70
  variant: variant_name,
71
+ overlay: overlay,
55
72
  db: config.data["db"],
56
73
  env: config.env_config
57
74
  )
58
75
  end
59
76
 
60
77
  # Hostname for one process: primary (first proxy:true) is bare;
61
- # others are proc.app.tld; proxy:false have none.
78
+ # others are proc.app.tld; proxy:false have none. A variant — the
79
+ # explicit name or a worktree's branch — leads every one of them.
62
80
  def hostname_for(result, proc_name)
63
81
  entry = result.processes[proc_name]
64
82
  return nil unless entry
65
83
  return nil if entry["proxy"] == false
66
84
 
67
- base = result.host ? result.host : "#{result.app}.#{result.tld}"
68
- proc_name.to_s == primary_proc(result) ? base : "#{proc_name}.#{base}"
85
+ # An explicit proxy.host is a full hostname, so composition does
86
+ # not apply — and a variant must never silently rewrite a host
87
+ # the user wrote out in full.
88
+ if result.host
89
+ return proc_name.to_s == primary_proc(result) ? result.host : "#{proc_name}.#{result.host}"
90
+ end
91
+
92
+ Hostname.compose(
93
+ app: result.app,
94
+ tld: result.tld,
95
+ service: (proc_name.to_s == primary_proc(result) ? nil : proc_name.to_s),
96
+ variant: result.variant
97
+ )
69
98
  end
70
99
 
71
100
  def primary_proc(result)
@@ -51,12 +51,27 @@ module Yamine
51
51
  label.empty? ? nil : [label, source]
52
52
  end
53
53
 
54
+ # The whole branch, not just its last path segment. `feature/login`
55
+ # and `bugfix/login` are different worktrees and must not end up
56
+ # sharing a hostname; taking the last segment alone collapsed them
57
+ # both to `login`.
54
58
  def branch_to_prefix(branch)
55
59
  return nil if branch.nil? || branch.empty?
56
60
  return nil if branch == "HEAD" || DEFAULT_BRANCHES.include?(branch)
57
61
 
58
- last = branch.split("/").last.to_s
59
- label = Sanitize.hostname_label(last)
62
+ label = Sanitize.hostname_label(branch)
63
+ label.empty? ? nil : label
64
+ end
65
+
66
+ # A detached HEAD has no branch to name it, so the worktree's
67
+ # directory does — the same identity the per-worktree database is
68
+ # keyed on. Without this, a detached worktree would fall back to the
69
+ # bare app name and answer on the main checkout's hostname.
70
+ def worktree_dir_prefix(cwd)
71
+ top, status = git(cwd, "rev-parse", "--show-toplevel")
72
+ return nil unless status.success?
73
+
74
+ label = Sanitize.hostname_label(File.basename(top.strip))
60
75
  label.empty? ? nil : label
61
76
  end
62
77
 
@@ -81,7 +96,7 @@ module Yamine
81
96
  branch, s3 = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
82
97
  return nil unless s3.success?
83
98
 
84
- branch_to_prefix(branch.strip)
99
+ branch_to_prefix(branch.strip) || worktree_dir_prefix(cwd)
85
100
  rescue SystemCallError
86
101
  filesystem_worktree_prefix(cwd)
87
102
  end
@@ -98,8 +113,10 @@ module Yamine
98
113
  if match && match[1].match?(%r{[/\\]worktrees[/\\][^/\\]+\z})
99
114
  head = File.join(File.expand_path(match[1], dir.to_s), "HEAD")
100
115
  branch = read_branch_from_head(head)
101
- prefix = branch_to_prefix(branch.to_s)
102
- return prefix ? [prefix, "git worktree"].first : nil
116
+ # Same rule as the git path: branch when there is one, the
117
+ # worktree directory otherwise (detached HEAD).
118
+ prefix = branch_to_prefix(branch.to_s) || Sanitize.hostname_label(dir.basename.to_s)
119
+ return prefix.empty? ? nil : prefix
103
120
  end
104
121
  return nil
105
122
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.11.1"
4
+ VERSION = "0.12.0"
5
5
  end
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.11.1
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto