ctree 0.5.3
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 +7 -0
- data/CHANGELOG.md +63 -0
- data/LICENSE +21 -0
- data/README.md +491 -0
- data/bin/ctree +7 -0
- data/lib/ctree/cli.rb +402 -0
- data/lib/ctree/compose_config_cmd.rb +136 -0
- data/lib/ctree/config.rb +242 -0
- data/lib/ctree/config.yml +111 -0
- data/lib/ctree/create.rb +714 -0
- data/lib/ctree/delete.rb +313 -0
- data/lib/ctree/domain.rb +158 -0
- data/lib/ctree/env_cmd.rb +123 -0
- data/lib/ctree/free.rb +79 -0
- data/lib/ctree/list.rb +101 -0
- data/lib/ctree/rebase.rb +221 -0
- data/lib/ctree/sync.rb +17 -0
- data/lib/ctree/update.rb +354 -0
- data/lib/ctree/version.rb +5 -0
- data/lib/ctree.rb +935 -0
- metadata +78 -0
data/lib/ctree/delete.rb
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module Delete
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(name:, force: false)
|
|
8
|
+
source_root = Pathname.pwd
|
|
9
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--show-toplevel")
|
|
10
|
+
Log.die "not inside a git repository" unless status.success?
|
|
11
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
12
|
+
unless source_root.realpath == git_top.realpath
|
|
13
|
+
Log.die "must be invoked from project top-level (cwd=#{source_root}, top=#{git_top})"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
if source_root.basename.to_s == name
|
|
17
|
+
Log.die "refusing to remove '#{name}' — that's the current source project itself"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
parent_dir = source_root.parent
|
|
21
|
+
target_project = Naming.sanitize_compose_project_name(name)
|
|
22
|
+
|
|
23
|
+
# Resolve the actual worktree path from git's registration first — the
|
|
24
|
+
# registered absolute path may differ from parent_dir/name if the
|
|
25
|
+
# worktree was created from a different pwd or via a symlinked directory.
|
|
26
|
+
# Fall back to constructing the path if git has no matching entry.
|
|
27
|
+
worktree_list_out, _, _ = Sh.capture3("git", "-C", source_root.to_s, "worktree", "list", "--porcelain")
|
|
28
|
+
wt_records = worktree_list_out.split(/\n\n+/).filter_map do |block|
|
|
29
|
+
record = {}
|
|
30
|
+
block.each_line do |line|
|
|
31
|
+
line = line.chomp
|
|
32
|
+
record[:path] = line.delete_prefix("worktree ") if line.start_with?("worktree ")
|
|
33
|
+
record[:branch] = line.delete_prefix("branch refs/heads/") if line.start_with?("branch refs/heads/")
|
|
34
|
+
end
|
|
35
|
+
record unless record.empty?
|
|
36
|
+
end
|
|
37
|
+
matching_record = wt_records.find { |r| r[:path] && File.basename(r[:path]) == name }
|
|
38
|
+
registered_wt_path = matching_record&.fetch(:path, nil)
|
|
39
|
+
worktree_branch = matching_record&.fetch(:branch, nil)
|
|
40
|
+
target_path = registered_wt_path ? Pathname.new(registered_wt_path) : (parent_dir / name)
|
|
41
|
+
|
|
42
|
+
# Discover what currently exists for this name.
|
|
43
|
+
docker_ok = Sh.system("docker", "info", out: File::NULL, err: File::NULL)
|
|
44
|
+
Log.warn_ "docker not reachable — volumes and compose stack will not be touched" unless docker_ok
|
|
45
|
+
|
|
46
|
+
# Read the worktree's compose override file (if present) to find any
|
|
47
|
+
# `external: true` volumes whose explicit `name:` is NOT under the
|
|
48
|
+
# target project prefix. Those are source-shared (e.g. yarn-cache
|
|
49
|
+
# mounted from the source project) and must be preserved during removal.
|
|
50
|
+
config = Config.load(source_root)
|
|
51
|
+
Log.log_prefix = config[:log_prefix]
|
|
52
|
+
override_rel = config[:compose_override_file].to_s
|
|
53
|
+
shared_external_vols = []
|
|
54
|
+
override_path = override_rel.empty? ? nil : (target_path / override_rel)
|
|
55
|
+
if override_path&.file?
|
|
56
|
+
begin
|
|
57
|
+
override_doc = YAML.safe_load(File.read(override_path.to_s), aliases: true)
|
|
58
|
+
if override_doc.is_a?(Hash) && override_doc["volumes"].is_a?(Hash)
|
|
59
|
+
override_doc["volumes"].each_value do |v|
|
|
60
|
+
next unless v.is_a?(Hash) && v["external"]
|
|
61
|
+
nm = v["name"]
|
|
62
|
+
next unless nm.is_a?(String) && !nm.empty?
|
|
63
|
+
next if nm.start_with?("#{target_project}_")
|
|
64
|
+
shared_external_vols << nm
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
rescue Psych::SyntaxError => e
|
|
68
|
+
Log.warn_ "could not parse #{override_path}: #{e.message}; cannot identify shared volumes for preservation"
|
|
69
|
+
end
|
|
70
|
+
shared_external_vols.uniq!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
vols = []
|
|
74
|
+
if docker_ok
|
|
75
|
+
vols_out, _, st = Sh.capture3("docker", "volume", "ls", "--format", "{{.Name}}")
|
|
76
|
+
Log.die "docker volume ls failed" unless st.success?
|
|
77
|
+
prefix_re = /\A#{Regexp.escape(target_project)}_/
|
|
78
|
+
vols = vols_out.lines.map(&:strip).reject(&:empty?).select { |v| v =~ prefix_re }.sort
|
|
79
|
+
# Belt-and-suspenders: explicitly drop any source-shared volume even
|
|
80
|
+
# though the prefix filter above should already exclude them.
|
|
81
|
+
vols -= shared_external_vols
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
containers = []
|
|
85
|
+
if docker_ok
|
|
86
|
+
ps_out, _, st = Sh.capture3(
|
|
87
|
+
"docker", "ps", "--all",
|
|
88
|
+
"--filter", "label=com.docker.compose.project=#{target_project}",
|
|
89
|
+
"--format", "{{.Names}}"
|
|
90
|
+
)
|
|
91
|
+
containers = ps_out.lines.map(&:strip).reject(&:empty?).sort if st.success?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
tagged_images = []
|
|
95
|
+
if docker_ok
|
|
96
|
+
imgs_out, _, st = Sh.capture3("docker", "images",
|
|
97
|
+
"--format", "{{.Repository}}",
|
|
98
|
+
"--filter", "reference=#{target_project}-*")
|
|
99
|
+
tagged_images = imgs_out.lines.map(&:strip).reject(&:empty?).sort if st.success?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
branch_to_check = worktree_branch || name
|
|
103
|
+
_, _, st = Sh.capture3("git", "-C", source_root.to_s, "show-ref", "--verify", "--quiet", "refs/heads/#{branch_to_check}")
|
|
104
|
+
branch_exists = st.success?
|
|
105
|
+
|
|
106
|
+
target_real = target_path.exist? ? target_path.realpath.to_s : target_path.to_s
|
|
107
|
+
worktree_registered = !registered_wt_path.nil?
|
|
108
|
+
|
|
109
|
+
# Branches are never deleted by remove (see below), so the presence
|
|
110
|
+
# of a same-named branch alone is not a reason to do work.
|
|
111
|
+
nothing_to_do = vols.empty? && containers.empty? && tagged_images.empty? && !worktree_registered && !target_path.exist?
|
|
112
|
+
if nothing_to_do
|
|
113
|
+
Log.info "nothing to remove for '#{name}' — no worktree, branch, containers, or volumes found"
|
|
114
|
+
exit 0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Show the user exactly what will happen.
|
|
118
|
+
summary = []
|
|
119
|
+
summary << ""
|
|
120
|
+
summary << "[#{PROG}] About to delete '#{name}':"
|
|
121
|
+
summary << ""
|
|
122
|
+
summary << " worktree path: #{target_path}"
|
|
123
|
+
summary << " - dir: #{target_path.exist? ? "EXISTS — will be deleted" : "absent"}"
|
|
124
|
+
summary << " - registered: #{worktree_registered ? "YES — will run `git worktree remove --force`" : "no"}"
|
|
125
|
+
summary << ""
|
|
126
|
+
if branch_exists
|
|
127
|
+
summary << " git branch: #{branch_to_check} — preserved (run `git branch -D #{branch_to_check}` manually to delete)"
|
|
128
|
+
else
|
|
129
|
+
summary << " git branch: #{branch_to_check} — not found"
|
|
130
|
+
end
|
|
131
|
+
summary << ""
|
|
132
|
+
summary << " compose stack: -p #{target_project}"
|
|
133
|
+
if containers.empty?
|
|
134
|
+
summary << " - containers: none running for this project"
|
|
135
|
+
else
|
|
136
|
+
summary << " - containers (will be stopped and removed via `docker compose down`):"
|
|
137
|
+
containers.each { |c| summary << " #{c}" }
|
|
138
|
+
end
|
|
139
|
+
summary << ""
|
|
140
|
+
if vols.empty?
|
|
141
|
+
summary << " docker volumes: none matching ^#{target_project}_"
|
|
142
|
+
else
|
|
143
|
+
summary << " docker volumes (#{vols.size}, all will be PERMANENTLY deleted):"
|
|
144
|
+
vols.each { |v| summary << " #{v}" }
|
|
145
|
+
end
|
|
146
|
+
summary << ""
|
|
147
|
+
unless shared_external_vols.empty?
|
|
148
|
+
summary << " shared volumes (mounted from source via override yml; will NOT be touched):"
|
|
149
|
+
shared_external_vols.sort.each { |v| summary << " #{v}" }
|
|
150
|
+
summary << ""
|
|
151
|
+
end
|
|
152
|
+
if tagged_images.empty?
|
|
153
|
+
summary << " docker images: no tags matching #{target_project}-*"
|
|
154
|
+
else
|
|
155
|
+
summary << " docker image tags (#{tagged_images.size}, tags will be deleted — image layers preserved):"
|
|
156
|
+
tagged_images.each { |img| summary << " #{img}" }
|
|
157
|
+
end
|
|
158
|
+
summary << ""
|
|
159
|
+
summary << " Source project (#{source_root}) will NOT be touched."
|
|
160
|
+
summary << ""
|
|
161
|
+
|
|
162
|
+
Log.section(summary.join("\n"), interactive: true, force: force)
|
|
163
|
+
|
|
164
|
+
unless Prompt.confirm("Type 'yes' to confirm deletion of '#{name}':", default: nil, force: force)
|
|
165
|
+
warn "[#{PROG}] aborted: confirmation not given. No changes made."
|
|
166
|
+
exit 1
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# === execute ===
|
|
170
|
+
|
|
171
|
+
failures = []
|
|
172
|
+
|
|
173
|
+
if docker_ok && !containers.empty?
|
|
174
|
+
_, err, st = Spinner.with_spinner("stopping compose stack -p #{target_project}") do
|
|
175
|
+
Sh.capture3("docker", "compose", "-p", target_project, "down", chdir: target_path.to_s)
|
|
176
|
+
end
|
|
177
|
+
if st.success?
|
|
178
|
+
Log.info "compose stack stopped"
|
|
179
|
+
else
|
|
180
|
+
Log.warn_ "compose down returned non-zero: #{err.strip}"
|
|
181
|
+
failures << "compose down"
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if docker_ok && !tagged_images.empty?
|
|
186
|
+
_, rmi_err, rmi_st = Spinner.with_spinner("removing #{tagged_images.size} image tag(s)") do
|
|
187
|
+
Sh.capture3("docker", "rmi", *tagged_images)
|
|
188
|
+
end
|
|
189
|
+
if rmi_st.success?
|
|
190
|
+
Log.info "deleted #{tagged_images.size} image tag(s): #{tagged_images.join(", ")}"
|
|
191
|
+
else
|
|
192
|
+
Log.warn_ "docker rmi returned non-zero: #{rmi_err.strip}"
|
|
193
|
+
failures << "image rmi"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if docker_ok && !vols.empty?
|
|
198
|
+
# Batch removal: single subprocess call, single round-trip to the
|
|
199
|
+
# docker daemon. Falls back to per-volume on partial failure so
|
|
200
|
+
# error messages remain attributable.
|
|
201
|
+
_, batch_err, batch_st = Spinner.with_spinner("removing #{vols.size} volume(s)") do
|
|
202
|
+
Sh.capture3("docker", "volume", "rm", *vols)
|
|
203
|
+
end
|
|
204
|
+
if batch_st.success?
|
|
205
|
+
Log.info "deleted #{vols.size} volume(s): #{vols.join(", ")}"
|
|
206
|
+
else
|
|
207
|
+
Log.warn_ "batch volume removal failed; retrying one at a time: #{batch_err.strip}"
|
|
208
|
+
vols.each do |v|
|
|
209
|
+
_, _, exists_st = Sh.capture3("docker", "volume", "inspect", v)
|
|
210
|
+
unless exists_st.success?
|
|
211
|
+
Log.info "deleted volume #{v} (already gone)"
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
_, err, st = Spinner.with_spinner("removing volume #{v}") do
|
|
215
|
+
Sh.capture3("docker", "volume", "rm", v)
|
|
216
|
+
end
|
|
217
|
+
if st.success?
|
|
218
|
+
Log.info "deleted volume #{v}"
|
|
219
|
+
else
|
|
220
|
+
Log.warn_ "failed to remove volume #{v}: #{err.strip}"
|
|
221
|
+
failures << "volume rm #{v}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Worktree directory removal: rename + background-detached rm -rf.
|
|
228
|
+
# On the same filesystem, rename(2) is O(1) so remove returns in
|
|
229
|
+
# seconds regardless of how many files are in the worktree. The
|
|
230
|
+
# detached rm survives shell exit via Process.spawn's pgroup: true.
|
|
231
|
+
# On cross-filesystem rename (EXDEV) or rename failure for any other
|
|
232
|
+
# reason, we fall back to synchronous `git worktree remove --force`.
|
|
233
|
+
if worktree_registered || target_path.exist?
|
|
234
|
+
remove_failed = false
|
|
235
|
+
|
|
236
|
+
if target_path.exist?
|
|
237
|
+
quarantine = quarantine_path_for(target_path)
|
|
238
|
+
|
|
239
|
+
begin
|
|
240
|
+
File.rename(target_path.to_s, quarantine.to_s)
|
|
241
|
+
Log.info "worktree dir renamed -> #{quarantine.basename}"
|
|
242
|
+
|
|
243
|
+
bg_pid = Sh.spawn(
|
|
244
|
+
"rm", "-rf", quarantine.to_s,
|
|
245
|
+
out: File::NULL, err: File::NULL, in: File::NULL,
|
|
246
|
+
pgroup: true
|
|
247
|
+
)
|
|
248
|
+
Sh.detach(bg_pid)
|
|
249
|
+
Log.info "queued background worktree dir delete job (pid #{bg_pid})"
|
|
250
|
+
rescue Errno::EXDEV, Errno::EACCES, Errno::EPERM, SystemCallError => e
|
|
251
|
+
Log.warn_ "rename failed (#{e.class}: #{e.message}); falling back to synchronous removal"
|
|
252
|
+
_, fallback_err, fallback_st = Spinner.with_spinner("removing worktree #{target_path}") do
|
|
253
|
+
Sh.capture3("git", "-C", source_root.to_s, "worktree", "remove", "--force", target_path.to_s)
|
|
254
|
+
end
|
|
255
|
+
unless fallback_st.success?
|
|
256
|
+
Log.warn_ "git worktree remove failed: #{fallback_err.strip}"
|
|
257
|
+
begin
|
|
258
|
+
FileUtils.rm_rf(target_path.to_s, secure: true)
|
|
259
|
+
if target_path.exist?
|
|
260
|
+
Log.warn_ "fallback rm_rf left #{target_path} behind"
|
|
261
|
+
remove_failed = true
|
|
262
|
+
else
|
|
263
|
+
Log.info "removed directory #{target_path} (fallback)"
|
|
264
|
+
end
|
|
265
|
+
rescue StandardError => rmerr
|
|
266
|
+
Log.warn_ "fallback rm_rf failed for #{target_path}: #{rmerr.message}"
|
|
267
|
+
remove_failed = true
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Prune always — cleans up the dangling registration after the
|
|
274
|
+
# rename, or any leftover from a prior failed removal.
|
|
275
|
+
_, prune_err, prune_st = Sh.capture3("git", "-C", source_root.to_s, "worktree", "prune")
|
|
276
|
+
if prune_st.success?
|
|
277
|
+
Log.info "pruned worktree registration"
|
|
278
|
+
else
|
|
279
|
+
Log.warn_ "git worktree prune failed: #{prune_err.strip}"
|
|
280
|
+
failures << "worktree prune"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
failures << "worktree remove" if remove_failed
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Branches are intentionally preserved — see the README "remove"
|
|
287
|
+
# section for rationale. Even an auto-derived branch (created when
|
|
288
|
+
# `ctree add` is called without an explicit branch name) can carry
|
|
289
|
+
# commits the user wants to keep, push, or merge later. To clean up
|
|
290
|
+
# an orphan, run `git branch -D <name>` manually.
|
|
291
|
+
|
|
292
|
+
puts unless LogFile.enabled?
|
|
293
|
+
if failures.empty?
|
|
294
|
+
exit 0
|
|
295
|
+
else
|
|
296
|
+
Log.warn_ "removal completed with failures: #{failures.join(", ")}"
|
|
297
|
+
exit 2
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Builds a unique sibling path adjacent to target_path used as the
|
|
302
|
+
# rename destination. Includes the destroying process's pid and a
|
|
303
|
+
# short random suffix so concurrent destroys of distinct worktrees
|
|
304
|
+
# never collide and a re-run after a stuck prior attempt doesn't
|
|
305
|
+
# either.
|
|
306
|
+
def quarantine_path_for(target_path)
|
|
307
|
+
base = target_path.basename.to_s
|
|
308
|
+
parent = target_path.parent
|
|
309
|
+
suffix = ".ctree-destroying-#{Process.pid}-#{rand(2**32).to_s(36)}"
|
|
310
|
+
parent / "#{base}#{suffix}"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
data/lib/ctree/domain.rb
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module Domain
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
RESOLVER_DIR = "/etc/resolver"
|
|
8
|
+
|
|
9
|
+
def macos?
|
|
10
|
+
RUBY_PLATFORM.include?("darwin")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def apply_config_log_prefix!
|
|
14
|
+
config = Config.load(Pathname.pwd)
|
|
15
|
+
Log.log_prefix = config[:log_prefix]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(tld:)
|
|
19
|
+
Log.die "ctree domain is only supported on macOS" unless macos?
|
|
20
|
+
apply_config_log_prefix!
|
|
21
|
+
container, conf_path, port = find_dnsmasq!
|
|
22
|
+
|
|
23
|
+
entry = "address=/.#{tld}/127.0.0.1"
|
|
24
|
+
lines = read_conf(conf_path)
|
|
25
|
+
if lines.any? { |l| l.strip == entry }
|
|
26
|
+
Log.info "dnsmasq already has entry for .#{tld}; skipping"
|
|
27
|
+
else
|
|
28
|
+
File.write(conf_path, (lines + ["#{entry}\n"]).join)
|
|
29
|
+
Log.info "added #{entry} to #{conf_path}"
|
|
30
|
+
restart_container(container)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
write_resolver(tld, port)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def remove(tld:)
|
|
37
|
+
Log.die "ctree domain is only supported on macOS" unless macos?
|
|
38
|
+
apply_config_log_prefix!
|
|
39
|
+
container, conf_path, _ = find_dnsmasq!
|
|
40
|
+
|
|
41
|
+
entry = "address=/.#{tld}/127.0.0.1"
|
|
42
|
+
lines = read_conf(conf_path)
|
|
43
|
+
filtered = lines.reject { |l| l.strip == entry }
|
|
44
|
+
if filtered.size == lines.size
|
|
45
|
+
Log.info "no dnsmasq entry found for .#{tld}"
|
|
46
|
+
else
|
|
47
|
+
File.write(conf_path, filtered.join)
|
|
48
|
+
Log.info "removed #{entry} from #{conf_path}"
|
|
49
|
+
restart_container(container)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
remove_resolver(tld)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def list
|
|
56
|
+
Log.die "ctree domain is only supported on macOS" unless macos?
|
|
57
|
+
apply_config_log_prefix!
|
|
58
|
+
_, conf_path, _ = find_dnsmasq!
|
|
59
|
+
lines = read_conf(conf_path)
|
|
60
|
+
tlds = lines.filter_map do |line|
|
|
61
|
+
m = line.strip.match(/\Aaddress=\/\.(\S+)\/127\.0\.0\.1\z/)
|
|
62
|
+
m[1] if m
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if tlds.empty?
|
|
66
|
+
puts "no domains configured in #{conf_path}"
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
tlds.each do |tld|
|
|
71
|
+
resolver_path = File.join(RESOLVER_DIR, tld)
|
|
72
|
+
status = File.exist?(resolver_path) ? "ok" : "missing"
|
|
73
|
+
puts "#{tld} (resolver: #{status})"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def find_dnsmasq!
|
|
78
|
+
out, _, st = Sh.capture3("docker", "ps", "--quiet", "--filter", "status=running")
|
|
79
|
+
Log.die "docker not available" unless st.success?
|
|
80
|
+
|
|
81
|
+
ids = out.lines.map(&:strip).reject(&:empty?)
|
|
82
|
+
Log.die "no running containers found; is Docker running?" if ids.empty?
|
|
83
|
+
|
|
84
|
+
ids.each do |id|
|
|
85
|
+
inspect, _, ist = Sh.capture3("docker", "inspect", id,
|
|
86
|
+
"--format",
|
|
87
|
+
"{{.Name}}\t{{.Config.Image}}\t{{json .HostConfig.Binds}}\t{{json .HostConfig.PortBindings}}")
|
|
88
|
+
next unless ist.success?
|
|
89
|
+
|
|
90
|
+
name, image, binds_json, ports_json = inspect.strip.split("\t", 4)
|
|
91
|
+
next unless image&.include?("dnsmasq")
|
|
92
|
+
|
|
93
|
+
binds = JSON.parse(binds_json || "[]")
|
|
94
|
+
conf_path = binds.filter_map { |b| b.split(":").first if b.include?("/etc/dnsmasq.conf") }.first
|
|
95
|
+
Log.die "found dnsmasq container #{name} but cannot locate config file via volume mounts" unless conf_path
|
|
96
|
+
|
|
97
|
+
ports = JSON.parse(ports_json || "{}")
|
|
98
|
+
port = ports.values.flatten.first&.dig("HostPort") || "53"
|
|
99
|
+
|
|
100
|
+
return [name.delete_prefix("/"), conf_path, port]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Log.die "no running dnsmasq container found; is traefik-dns running?"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def read_conf(path)
|
|
107
|
+
File.exist?(path) ? File.readlines(path) : []
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def restart_container(name)
|
|
111
|
+
_, err, st = Spinner.with_spinner("restarting #{name}") do
|
|
112
|
+
Sh.capture3("docker", "restart", name)
|
|
113
|
+
end
|
|
114
|
+
if st.success?
|
|
115
|
+
Log.info "restarted #{name}"
|
|
116
|
+
else
|
|
117
|
+
Log.warn_ "docker restart #{name} failed: #{err.strip}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def write_resolver(tld, port)
|
|
122
|
+
path = File.join(RESOLVER_DIR, tld)
|
|
123
|
+
content = "nameserver 127.0.0.1\nport #{port}\n"
|
|
124
|
+
|
|
125
|
+
if File.exist?(path) && File.read(path) == content
|
|
126
|
+
Log.info "#{path} already configured; skipping"
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
_, err, st = Sh.capture3("sudo", "tee", path, stdin_data: content)
|
|
131
|
+
if st.success?
|
|
132
|
+
Log.info "wrote #{path}"
|
|
133
|
+
else
|
|
134
|
+
Log.warn_ "could not write #{path} (#{err.strip})"
|
|
135
|
+
Log.warn_ "run manually: sudo bash -c 'printf \"nameserver 127.0.0.1\\nport #{port}\\n\" > #{path}'"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def remove_resolver(tld)
|
|
140
|
+
path = File.join(RESOLVER_DIR, tld)
|
|
141
|
+
unless File.exist?(path)
|
|
142
|
+
Log.info "#{path} does not exist; skipping"
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
_, err, st = Sh.capture3("sudo", "rm", path)
|
|
147
|
+
if st.success?
|
|
148
|
+
Log.info "removed #{path}"
|
|
149
|
+
else
|
|
150
|
+
Log.warn_ "could not remove #{path} (#{err.strip})"
|
|
151
|
+
Log.warn_ "run manually: sudo rm #{path}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
private_class_method :find_dnsmasq!, :read_conf, :restart_container,
|
|
156
|
+
:write_resolver, :remove_resolver, :apply_config_log_prefix!
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
module EnvCmd
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def run(subcommand, force: false)
|
|
8
|
+
target_path, source_root = resolve_worktree!
|
|
9
|
+
config = Config.load(source_root)
|
|
10
|
+
Log.log_prefix = config[:log_prefix]
|
|
11
|
+
skip_keys = config[:skip_env_keys]
|
|
12
|
+
|
|
13
|
+
case subcommand
|
|
14
|
+
when "list" then cmd_list(target_path, source_root, skip_keys, config[:env_filename])
|
|
15
|
+
when "check" then cmd_check(target_path, source_root, config)
|
|
16
|
+
when "fix" then cmd_fix(target_path, source_root, config, force: force)
|
|
17
|
+
else Log.die "unknown subcommand '#{subcommand}'; use: list, check, or fix"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve_worktree!
|
|
22
|
+
target_path = Pathname.pwd
|
|
23
|
+
toplevel_out, _, st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
|
|
24
|
+
Log.die "not inside a git repository" unless st.success?
|
|
25
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
26
|
+
unless target_path.realpath == git_top.realpath
|
|
27
|
+
Log.die "must be invoked from the worktree top-level"
|
|
28
|
+
end
|
|
29
|
+
common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
|
|
30
|
+
Log.die "could not resolve git common dir" unless c_st.success?
|
|
31
|
+
common = Pathname.new(common_out.strip)
|
|
32
|
+
common = (target_path / common) unless common.absolute?
|
|
33
|
+
source_root = common.parent.realpath
|
|
34
|
+
if source_root == target_path.realpath
|
|
35
|
+
Log.die "already in the source project; run ctree env from inside a worktree"
|
|
36
|
+
end
|
|
37
|
+
[target_path.realpath, source_root]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ctree_managed_keys(config)
|
|
41
|
+
keys = ["COMPOSE_PROJECT_NAME"]
|
|
42
|
+
host_name_key = config[:host_name].to_s.strip
|
|
43
|
+
keys << host_name_key unless host_name_key.empty?
|
|
44
|
+
keys << "HOST_NAME_SUFFIX" unless config[:host_name_suffix].to_s.strip.empty?
|
|
45
|
+
domain_key = config[:host_domain_env_key].to_s.strip
|
|
46
|
+
keys << domain_key unless domain_key.empty?
|
|
47
|
+
keys.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def cmd_list(target_path, source_root, skip_keys, env_filename)
|
|
51
|
+
tgt_env_path = target_path / env_filename
|
|
52
|
+
Log.die "no #{env_filename} found at #{tgt_env_path}" unless tgt_env_path.file?
|
|
53
|
+
src_env_path = source_root / env_filename
|
|
54
|
+
Log.die "no #{env_filename} found in source repo at #{src_env_path}" unless src_env_path.file?
|
|
55
|
+
|
|
56
|
+
tgt_env = EnvFile.parse(tgt_env_path.to_s)
|
|
57
|
+
tgt_env.each { |k, v| puts "#{k}=#{v}" }
|
|
58
|
+
exit 0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cmd_check(target_path, source_root, config)
|
|
62
|
+
env_filename = config[:env_filename]
|
|
63
|
+
tgt_env_path = target_path / env_filename
|
|
64
|
+
Log.die "no #{env_filename} found at #{tgt_env_path}" unless tgt_env_path.file?
|
|
65
|
+
src_env_path = source_root / env_filename
|
|
66
|
+
Log.die "no #{env_filename} found in source repo at #{src_env_path}" unless src_env_path.file?
|
|
67
|
+
|
|
68
|
+
tgt_env = EnvFile.parse(tgt_env_path.to_s)
|
|
69
|
+
src_env = EnvFile.parse(src_env_path.to_s)
|
|
70
|
+
|
|
71
|
+
managed = ctree_managed_keys(config)
|
|
72
|
+
missing = src_env.keys - tgt_env.keys
|
|
73
|
+
extra = tgt_env.keys - src_env.keys - managed - config[:skip_env_keys]
|
|
74
|
+
|
|
75
|
+
if missing.empty? && extra.empty?
|
|
76
|
+
Log.info "env is in sync"
|
|
77
|
+
exit 0
|
|
78
|
+
else
|
|
79
|
+
missing.each { |k| puts "[missing] #{k}" }
|
|
80
|
+
extra.each { |k| puts "[extra] #{k}" }
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cmd_fix(target_path, source_root, config, force: false)
|
|
86
|
+
env_filename = config[:env_filename]
|
|
87
|
+
tgt_env_path = target_path / env_filename
|
|
88
|
+
Log.die "no #{env_filename} found at #{tgt_env_path}" unless tgt_env_path.file?
|
|
89
|
+
src_env_path = source_root / env_filename
|
|
90
|
+
Log.die "no #{env_filename} found in source repo at #{src_env_path}" unless src_env_path.file?
|
|
91
|
+
|
|
92
|
+
tgt_env = EnvFile.parse(tgt_env_path.to_s)
|
|
93
|
+
src_env = EnvFile.parse(src_env_path.to_s)
|
|
94
|
+
|
|
95
|
+
managed = ctree_managed_keys(config)
|
|
96
|
+
effective_skip = (config[:skip_env_keys] + managed).uniq
|
|
97
|
+
missing = src_env.keys - tgt_env.keys - managed
|
|
98
|
+
orphaned = tgt_env.keys - src_env.keys - effective_skip
|
|
99
|
+
|
|
100
|
+
if missing.empty? && orphaned.empty?
|
|
101
|
+
Log.info "env is in sync; nothing to fix"
|
|
102
|
+
exit 0
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
missing.each do |key|
|
|
106
|
+
new_value = Prompt.for_env_var_change(key, src_env[key], force: force)
|
|
107
|
+
EnvFile.upsert(tgt_env_path.to_s, key, new_value)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
orphaned.each do |key|
|
|
111
|
+
if Prompt.confirm("#{key}=#{tgt_env[key]} (not in source #{env_filename}, delete? [y/N]):", default: :no, force: force)
|
|
112
|
+
EnvFile.delete(tgt_env_path.to_s, key)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Log.info "updated #{env_filename}"
|
|
117
|
+
exit 0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private_class_method :resolve_worktree!, :ctree_managed_keys,
|
|
121
|
+
:cmd_list, :cmd_check, :cmd_fix
|
|
122
|
+
end
|
|
123
|
+
end
|
data/lib/ctree/free.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Ctree
|
|
6
|
+
module Free
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def run
|
|
10
|
+
target_path = Pathname.pwd
|
|
11
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
|
|
12
|
+
Log.die "not inside a git repository" unless status.success?
|
|
13
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
14
|
+
unless target_path.realpath == git_top.realpath
|
|
15
|
+
Log.die "must be invoked from the worktree top-level (cwd=#{target_path}, top=#{git_top})"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
|
|
19
|
+
Log.die "could not resolve git common dir" unless c_st.success?
|
|
20
|
+
common = Pathname.new(common_out.strip)
|
|
21
|
+
common = (target_path / common) unless common.absolute?
|
|
22
|
+
source_root = common.parent.realpath
|
|
23
|
+
|
|
24
|
+
if source_root == target_path.realpath
|
|
25
|
+
Log.die "already in the source project; run ctree free from inside a worktree"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config = Config.load(source_root)
|
|
29
|
+
Log.log_prefix = config[:log_prefix]
|
|
30
|
+
prefix = config[:free_branch_prefix]
|
|
31
|
+
|
|
32
|
+
current_branch_out, _, cb_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--abbrev-ref", "HEAD")
|
|
33
|
+
Log.die "could not resolve current branch" unless cb_st.success?
|
|
34
|
+
current_branch = current_branch_out.strip
|
|
35
|
+
if !prefix.empty? && current_branch.start_with?(prefix)
|
|
36
|
+
Log.info "already on a free branch (#{current_branch}); nothing to do"
|
|
37
|
+
exit 0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
branches_out, _, branches_st = Sh.capture3("git", "-C", source_root.to_s, "branch", "--list", "#{prefix}*")
|
|
41
|
+
Log.die "git branch --list failed" unless branches_st.success?
|
|
42
|
+
all_free = branches_out.lines.map { |l| l.sub(/\A[*+ ] /, "").strip }.reject(&:empty?).sort
|
|
43
|
+
|
|
44
|
+
worktree_raw, _, wt_st = Sh.capture3("git", "-C", source_root.to_s, "worktree", "list", "--porcelain")
|
|
45
|
+
Log.die "git worktree list failed" unless wt_st.success?
|
|
46
|
+
occupied = List.parse_worktree_porcelain(worktree_raw).filter_map { |e| e[:branch] }.to_set
|
|
47
|
+
|
|
48
|
+
branch = first_available_branch(all_free, occupied)
|
|
49
|
+
if branch
|
|
50
|
+
_, err, st = Spinner.with_spinner("checking out #{branch}") do
|
|
51
|
+
Sh.capture3("git", "-C", target_path.to_s, "checkout", branch)
|
|
52
|
+
end
|
|
53
|
+
Log.die "git checkout #{branch} failed: #{err.strip}" unless st.success?
|
|
54
|
+
else
|
|
55
|
+
branch = next_sequential_branch(prefix, all_free)
|
|
56
|
+
_, err, st = Spinner.with_spinner("checking out #{branch}") do
|
|
57
|
+
Sh.capture3("git", "-C", target_path.to_s, "checkout", "-b", branch)
|
|
58
|
+
end
|
|
59
|
+
Log.die "git checkout -b #{branch} failed: #{err.strip}" unless st.success?
|
|
60
|
+
end
|
|
61
|
+
Log.info "checked out #{branch}"
|
|
62
|
+
|
|
63
|
+
default_branch = Git.detect_default_branch(source_root)
|
|
64
|
+
Rebase.rebase_branch_onto_default(target_path, branch, default_branch)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def first_available_branch(all_free, occupied)
|
|
68
|
+
all_free.sort.find { |b| !occupied.include?(b) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def next_sequential_branch(prefix, all_free)
|
|
72
|
+
numeric_pattern = /\A#{Regexp.escape(prefix)}(\d+)\z/
|
|
73
|
+
numbers = all_free.filter_map { |b| b.match(numeric_pattern)&.then { |m| m[1].to_i } }.to_set
|
|
74
|
+
n = 1
|
|
75
|
+
n += 1 while numbers.include?(n)
|
|
76
|
+
"#{prefix}#{n.to_s.rjust(3, "0")}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|