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 +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +30 -2
- data/lib/ask/skills/yamine/SKILL.md +24 -2
- data/lib/yamine/cli/boot.rb +1 -1
- data/lib/yamine/cli/context.rb +15 -4
- data/lib/yamine/cli/system.rb +1 -52
- data/lib/yamine/cli/worktree.rb +534 -0
- data/lib/yamine/cli.rb +2 -2
- data/lib/yamine/doctor.rb +16 -4
- data/lib/yamine/hosts.rb +56 -21
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine/worktrees.rb +147 -0
- data/lib/yamine.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab7c7e888dc8066ad10a7ed2fa90dcd684b27aa171038ac0ae5ea4c06b22251b
|
|
4
|
+
data.tar.gz: '097d12693a71cf3969f481fd0f61b3f82e9a09d7958139e32b3fb79a7e667cd8'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
148
|
-
|
|
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
|
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -157,7 +157,7 @@ module Yamine
|
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
say opts
|
|
160
|
-
ctx.
|
|
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".
|
data/lib/yamine/cli/context.rb
CHANGED
|
@@ -35,11 +35,22 @@ module Yamine
|
|
|
35
35
|
ProxyControl.proxy_tls(store)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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:)
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
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
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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.
|
|
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
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
#
|
|
92
|
-
#
|
|
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
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
def
|
|
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
|
-
|
|
132
|
+
:ok
|
|
102
133
|
rescue SocketError, SystemCallError, Timeout::Error
|
|
103
|
-
|
|
134
|
+
:fail
|
|
104
135
|
end
|
|
105
136
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
data/lib/yamine/version.rb
CHANGED
|
@@ -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.
|
|
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
|