yamine 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +27 -1
- data/lib/ask/skills/yamine/SKILL.md +20 -0
- 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/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,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0] — 2026-09-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **yamine now owns the worktree lifecycle, from `add` to `clean`.**
|
|
8
|
+
It already gave every linked worktree its own URL, database, and
|
|
9
|
+
routes; it just never managed the worktree itself. Creating one meant
|
|
10
|
+
a manual `git worktree add`, re-creating the gitignored config by
|
|
11
|
+
hand, a bundle install, and a first boot that discovered everything
|
|
12
|
+
the hard way — and cleanup was hand-run `git worktree remove` with
|
|
13
|
+
routes, databases, and hosts entries left to rot.
|
|
14
|
+
|
|
15
|
+
`yamine worktree add <name>` creates the worktree beside the repo
|
|
16
|
+
(branch = name), copies `config/local.yml` and
|
|
17
|
+
`config/local.secrets` — the gitignored per-checkout files git does
|
|
18
|
+
not carry and the single largest follow-up cost — runs
|
|
19
|
+
`bundle install`, pre-creates the per-worktree database with schema,
|
|
20
|
+
and prints the URL the worktree will serve. `yamine worktree remove
|
|
21
|
+
<name>` is the full teardown of one worktree: stop its backends,
|
|
22
|
+
remove its routes, drop its database, `git worktree remove`, delete
|
|
23
|
+
the branch. `yamine worktree clean [--all] [--dry-run]` is the
|
|
24
|
+
done-and-merged sweep: full teardown of every merged worktree plus
|
|
25
|
+
the old orphan sweep, `list` shows every worktree with its database,
|
|
26
|
+
dirty, and merged status.
|
|
27
|
+
|
|
28
|
+
The safety rails are load-bearing: the main checkout is never a
|
|
29
|
+
candidate; `clean` never touches a worktree with uncommitted changes
|
|
30
|
+
(and disregards only the config yamine itself copied, so a fresh
|
|
31
|
+
worktree is not born dirty); branches go with `git branch -d`, so
|
|
32
|
+
`clean --all` may remove an unmerged worktree but never its branch —
|
|
33
|
+
only `remove --force` discards changes or unmerged refs; a failed
|
|
34
|
+
database drop aborts before the directory is touched, keeping the
|
|
35
|
+
claim for a retry. A sqlite or template-less app needs no server
|
|
36
|
+
drop, so its claims are forgotten instead of failing forever — the
|
|
37
|
+
same honesty 0.13.1 gave postgres, extended to the apps that never
|
|
38
|
+
had a server to begin with.
|
|
39
|
+
|
|
3
40
|
## [0.13.2] — 2026-09-12
|
|
4
41
|
|
|
5
42
|
### Fixed
|
data/README.md
CHANGED
|
@@ -158,6 +158,32 @@ A variant is a hostname label, not a config file. Only an explicit
|
|
|
158
158
|
branch named like a file on disk cannot change your config by accident.
|
|
159
159
|
`yamine status` prints both lines so the two are never confused.
|
|
160
160
|
|
|
161
|
+
## Worktree lifecycle
|
|
162
|
+
|
|
163
|
+
yamine owns a worktree from creation to removal:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
yamine worktree add feature/login # worktree + config + database, ready to boot
|
|
167
|
+
yamine worktree list # every worktree: db, dirty, merged
|
|
168
|
+
yamine worktree remove feature/login # stop, drop db, remove worktree
|
|
169
|
+
yamine worktree clean # tear down everything already merged
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`add` lands the worktree beside the repo, copies the gitignored
|
|
173
|
+
per-checkout config (`config/local.yml`, `config/local.secrets`) the
|
|
174
|
+
branch needs, runs `bundle install`, and pre-creates the per-worktree
|
|
175
|
+
database with schema — the next step is just `yamine start` in it.
|
|
176
|
+
|
|
177
|
+
`clean` is the done-and-merged sweep: it tears down every worktree
|
|
178
|
+
whose branch is merged (stop, drop database, remove worktree, delete
|
|
179
|
+
branch) and forgets claims of directories that no longer exist. It
|
|
180
|
+
never touches a worktree with uncommitted changes, and unmerged
|
|
181
|
+
branches survive every path except `remove --force` — `git branch -d`
|
|
182
|
+
refuses to delete what git has not seen merged, so a wrong merge
|
|
183
|
+
detection cannot lose a branch. `--all` includes clean-but-unmerged
|
|
184
|
+
worktrees (the branch stays); `--dry-run` prints the plan; `remove
|
|
185
|
+
--force` is the only command that discards uncommitted changes.
|
|
186
|
+
|
|
161
187
|
```bash
|
|
162
188
|
yamine # -> https://myapp.localhost
|
|
163
189
|
yamine --service api # -> https://api.myapp.localhost
|
|
@@ -202,7 +228,7 @@ yamine trust # add local CA to system trust store
|
|
|
202
228
|
yamine clean # remove state and hosts entries
|
|
203
229
|
yamine prune # remove stale routes
|
|
204
230
|
yamine db list|create|drop # per-worktree databases
|
|
205
|
-
yamine worktree list|clean
|
|
231
|
+
yamine worktree list|add|remove|clean # worktree lifecycle
|
|
206
232
|
yamine stop # stop this app's backend + routes
|
|
207
233
|
yamine restart # touch tmp/restart.txt (managed apps reboot)
|
|
208
234
|
yamine log [-f] [n] # tail (or follow) this app's backend log
|
|
@@ -98,6 +98,26 @@ Only an explicit variant ever looks for an overlay file — a branch name
|
|
|
98
98
|
that happens to match one on disk is ignored. `yamine status` shows
|
|
99
99
|
`variant:` and `overlay:` separately.
|
|
100
100
|
|
|
101
|
+
## Worktree lifecycle
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
yamine worktree add feature/login # create + make bootable
|
|
105
|
+
yamine worktree list # branch, dir, db, dirty/merged
|
|
106
|
+
yamine worktree remove feature/login
|
|
107
|
+
yamine worktree clean [--dry-run]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`add` creates the git worktree beside the repo, copies the gitignored
|
|
111
|
+
per-checkout config (`config/local.yml`, `config/local.secrets`), runs
|
|
112
|
+
`bundle install`, and pre-creates the per-worktree database — then boot
|
|
113
|
+
with `yamine start` inside it. `clean` tears down every worktree whose
|
|
114
|
+
branch is merged (stops backends, drops the database, removes the
|
|
115
|
+
worktree, deletes the branch) and forgets claims of directories that
|
|
116
|
+
vanished. It never touches uncommitted work; unmerged branches survive
|
|
117
|
+
everything except `remove --force`. Prefer `clean --dry-run` first, and
|
|
118
|
+
`clean` over `rm -rf` — a removed worktree leaves no routes, database,
|
|
119
|
+
or stale hosts entries behind.
|
|
120
|
+
|
|
101
121
|
## Subdomains are opt-in
|
|
102
122
|
|
|
103
123
|
A route answers its exact hostname. `*.myapp.localhost` reaches the app
|
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/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
|