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.rb
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "find"
|
|
5
|
+
require "json"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "pathname"
|
|
8
|
+
require "yaml"
|
|
9
|
+
|
|
10
|
+
begin
|
|
11
|
+
require "readline"
|
|
12
|
+
HAS_READLINE = true
|
|
13
|
+
rescue LoadError
|
|
14
|
+
HAS_READLINE = false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Ctree
|
|
18
|
+
PROG = "ctree"
|
|
19
|
+
NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/.freeze
|
|
20
|
+
# Branch names are more permissive than the worktree/compose name (which
|
|
21
|
+
# doubles as a Docker compose project prefix). Allow letters, digits, ., _,
|
|
22
|
+
# -, and /.
|
|
23
|
+
BRANCH_NAME_PATTERN = %r{\A[A-Za-z0-9][A-Za-z0-9._/-]*\z}.freeze
|
|
24
|
+
|
|
25
|
+
SPINNER_FRAMES = %w[| / - \\].freeze
|
|
26
|
+
|
|
27
|
+
module Log
|
|
28
|
+
@debug = false
|
|
29
|
+
@log_prefix = true
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def debug_mode=(val)
|
|
34
|
+
@debug = val
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def debug?
|
|
38
|
+
@debug
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_prefix=(val)
|
|
42
|
+
@log_prefix = val
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Test-only: specs run in one process, so state must be reset between examples
|
|
46
|
+
def reset!
|
|
47
|
+
@debug = false
|
|
48
|
+
@log_prefix = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log_prefix?
|
|
52
|
+
@log_prefix
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def prefix
|
|
56
|
+
log_prefix? ? "[#{PROG}] " : ""
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def debug(msg)
|
|
60
|
+
if LogFile.enabled?
|
|
61
|
+
LogFile.write(msg)
|
|
62
|
+
elsif @debug
|
|
63
|
+
puts "#{prefix}#{msg}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def info(msg)
|
|
68
|
+
if LogFile.enabled?
|
|
69
|
+
LogFile.write(msg)
|
|
70
|
+
else
|
|
71
|
+
puts "#{prefix}#{msg}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def warn_(msg)
|
|
76
|
+
if LogFile.enabled?
|
|
77
|
+
LogFile.write("WARNING: #{msg}")
|
|
78
|
+
else
|
|
79
|
+
warn "#{prefix}WARNING: #{msg}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def die(msg)
|
|
84
|
+
if LogFile.enabled?
|
|
85
|
+
LogFile.write("ERROR: #{msg}")
|
|
86
|
+
Scroller.stop
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Always show the full message — for hook failures it is the
|
|
90
|
+
# error output, and the log file already holds the same text
|
|
91
|
+
warn "#{prefix}ERROR: #{msg}"
|
|
92
|
+
$stderr.flush
|
|
93
|
+
exit 1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `interactive: true` marks content that exists only to precede a
|
|
97
|
+
# confirmation prompt (e.g. delete's listing) — skipped from the log
|
|
98
|
+
# file entirely once forced, since it's never shown and is redundant.
|
|
99
|
+
def section(text, interactive: false, force: false)
|
|
100
|
+
if LogFile.enabled?
|
|
101
|
+
LogFile.write_block(text) unless interactive && force
|
|
102
|
+
if interactive && !force
|
|
103
|
+
Scroller.pause
|
|
104
|
+
puts text
|
|
105
|
+
end
|
|
106
|
+
else
|
|
107
|
+
puts text
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
module LogFile
|
|
113
|
+
module_function
|
|
114
|
+
|
|
115
|
+
def configure(path)
|
|
116
|
+
@path = path
|
|
117
|
+
FileUtils.touch(path)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def enabled?
|
|
121
|
+
!@path.nil?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def path
|
|
125
|
+
@path
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def write(msg)
|
|
129
|
+
return unless enabled?
|
|
130
|
+
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
131
|
+
File.open(@path, "a") { |f| f.puts("[#{ts}] #{msg}") }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# For one multi-line announcement (e.g. a static listing) that belongs to
|
|
135
|
+
# a single moment in time: timestamps only the first non-blank line and
|
|
136
|
+
# writes the rest as-is, instead of repeating a near-identical timestamp
|
|
137
|
+
# on every line.
|
|
138
|
+
def write_block(text)
|
|
139
|
+
return unless enabled?
|
|
140
|
+
lines = text.each_line(chomp: true).to_a
|
|
141
|
+
lines.shift while lines.first == ""
|
|
142
|
+
return if lines.empty?
|
|
143
|
+
ts = Time.now.strftime("%Y-%m-%d %H:%M:%S")
|
|
144
|
+
File.open(@path, "a") do |f|
|
|
145
|
+
f.puts("[#{ts}] #{lines.shift}")
|
|
146
|
+
lines.each { |line| f.puts(line) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Test-only: specs run in one process, so state must be reset between examples
|
|
151
|
+
def reset!
|
|
152
|
+
@path = nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
module Scroller
|
|
157
|
+
module_function
|
|
158
|
+
|
|
159
|
+
def start(msg)
|
|
160
|
+
@prefix = "#{Ctree::Log.prefix}#{msg}"
|
|
161
|
+
@start_time = Time.now
|
|
162
|
+
@mutex = Mutex.new
|
|
163
|
+
@paused = false
|
|
164
|
+
@stopped = false
|
|
165
|
+
if $stdout.tty?
|
|
166
|
+
@thread = Thread.new { render_loop }
|
|
167
|
+
else
|
|
168
|
+
@thread = nil
|
|
169
|
+
puts @prefix
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def pause
|
|
174
|
+
return unless @thread
|
|
175
|
+
@mutex.synchronize { @paused = true }
|
|
176
|
+
print "\r\e[K"
|
|
177
|
+
$stdout.flush
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def resume
|
|
181
|
+
return unless @thread
|
|
182
|
+
@mutex.synchronize { @paused = false }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Ends the scroller. With no args, just clears the animated line (or is
|
|
186
|
+
# a no-op if nothing was ever started). With `final_message`, replaces
|
|
187
|
+
# that line — same in-place-overwrite trick `Spinner.with_spinner`'s
|
|
188
|
+
# callers use — with a past-tense completion line and elapsed time.
|
|
189
|
+
def stop(final_message = nil)
|
|
190
|
+
elapsed = @start_time && (Time.now - @start_time).to_i
|
|
191
|
+
if @thread
|
|
192
|
+
@mutex.synchronize { @stopped = true }
|
|
193
|
+
@thread.join
|
|
194
|
+
@thread = nil
|
|
195
|
+
print "\r\e[K"
|
|
196
|
+
end
|
|
197
|
+
if final_message
|
|
198
|
+
suffix = elapsed ? " (#{elapsed}s)" : ""
|
|
199
|
+
puts "[#{PROG}] #{final_message}#{suffix}"
|
|
200
|
+
end
|
|
201
|
+
$stdout.flush
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def render_loop
|
|
205
|
+
idx = 0
|
|
206
|
+
until @mutex.synchronize { @stopped }
|
|
207
|
+
unless @mutex.synchronize { @paused }
|
|
208
|
+
elapsed = (Time.now - @start_time).to_i
|
|
209
|
+
line = format("%s (%s) %ds", @prefix,
|
|
210
|
+
SPINNER_FRAMES[idx % SPINNER_FRAMES.length], elapsed)
|
|
211
|
+
print "\r\e[K#{line}"
|
|
212
|
+
$stdout.flush
|
|
213
|
+
idx += 1
|
|
214
|
+
end
|
|
215
|
+
sleep 0.1
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private_class_method :render_loop
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Git helpers shared by all commands.
|
|
223
|
+
module Git
|
|
224
|
+
module_function
|
|
225
|
+
|
|
226
|
+
# Returns the source repo's default branch name: the branch
|
|
227
|
+
# refs/remotes/origin/HEAD points at when available, otherwise
|
|
228
|
+
# "master" if refs/heads/master exists, otherwise "main".
|
|
229
|
+
# Never returns nil.
|
|
230
|
+
def detect_default_branch(root)
|
|
231
|
+
ref_out, _, ref_st = Sh.capture3("git", "-C", root.to_s,
|
|
232
|
+
"symbolic-ref", "refs/remotes/origin/HEAD")
|
|
233
|
+
return ref_out.strip.sub("refs/remotes/origin/", "") if ref_st.success?
|
|
234
|
+
|
|
235
|
+
_, _, m_st = Sh.capture3("git", "-C", root.to_s,
|
|
236
|
+
"show-ref", "--verify", "--quiet", "refs/heads/master")
|
|
237
|
+
m_st.success? ? "master" : "main"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Single seam for shelling out. Integration tests stub these.
|
|
242
|
+
module Sh
|
|
243
|
+
module_function
|
|
244
|
+
|
|
245
|
+
def capture3(*cmd, **opts)
|
|
246
|
+
Open3.capture3(*cmd, **opts)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Merged stdout+stderr, in the order the subprocess produced it — used
|
|
250
|
+
# where we need a single readable transcript (e.g. a failing hook's
|
|
251
|
+
# interleaved progress/error output) rather than two separate buffers.
|
|
252
|
+
def capture2e(*cmd, **opts)
|
|
253
|
+
Open3.capture2e(*cmd, **opts)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def popen3(*cmd, &blk)
|
|
257
|
+
Open3.popen3(*cmd, &blk)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def system(*cmd, **kw)
|
|
261
|
+
Kernel.system(*cmd, **kw)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def spawn(*cmd, **kw)
|
|
265
|
+
Process.spawn(*cmd, **kw)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def detach(pid)
|
|
269
|
+
Process.detach(pid)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
module EnvFile
|
|
274
|
+
module_function
|
|
275
|
+
|
|
276
|
+
def parse(path)
|
|
277
|
+
return {} unless File.file?(path)
|
|
278
|
+
result = {}
|
|
279
|
+
File.foreach(path) do |raw|
|
|
280
|
+
line = raw.strip
|
|
281
|
+
next if line.empty? || line.start_with?("#")
|
|
282
|
+
next unless line.include?("=")
|
|
283
|
+
key, value = line.split("=", 2)
|
|
284
|
+
key = key.strip.sub(/\Aexport\s+/, "")
|
|
285
|
+
value = value.strip
|
|
286
|
+
if (value.start_with?('"') && value.end_with?('"')) ||
|
|
287
|
+
(value.start_with?("'") && value.end_with?("'"))
|
|
288
|
+
value = value[1..-2]
|
|
289
|
+
end
|
|
290
|
+
result[key] = value
|
|
291
|
+
end
|
|
292
|
+
result
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def delete(path, key)
|
|
296
|
+
return unless File.file?(path)
|
|
297
|
+
pattern = /\A\s*#{Regexp.escape(key)}\s*=/
|
|
298
|
+
lines = File.readlines(path).reject { |l| l =~ pattern }
|
|
299
|
+
File.write(path, lines.join)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def upsert(path, key, value)
|
|
303
|
+
new_line = "#{key}=#{value}"
|
|
304
|
+
unless File.file?(path)
|
|
305
|
+
File.write(path, new_line + "\n")
|
|
306
|
+
return :created
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
lines = File.readlines(path)
|
|
310
|
+
matched = false
|
|
311
|
+
pattern = /\A\s*#{Regexp.escape(key)}\s*=/
|
|
312
|
+
lines.map! do |line|
|
|
313
|
+
if line =~ pattern
|
|
314
|
+
matched = true
|
|
315
|
+
keep_newline = line.end_with?("\n") ? "\n" : ""
|
|
316
|
+
new_line + keep_newline
|
|
317
|
+
else
|
|
318
|
+
line
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
unless matched
|
|
323
|
+
if lines.any? && !lines.last.end_with?("\n")
|
|
324
|
+
lines[-1] = lines.last + "\n"
|
|
325
|
+
end
|
|
326
|
+
lines << new_line + "\n"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
File.write(path, lines.join)
|
|
330
|
+
matched ? :replaced : :appended
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
module Naming
|
|
335
|
+
module_function
|
|
336
|
+
|
|
337
|
+
def sanitize_compose_project_name(str)
|
|
338
|
+
out = str.downcase.gsub(/[^a-z0-9_-]/, "_")
|
|
339
|
+
out.sub(/\A[^a-z0-9]+/, "")
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
module Sizes
|
|
344
|
+
module_function
|
|
345
|
+
|
|
346
|
+
def human(bytes)
|
|
347
|
+
return "0B" unless bytes && bytes > 0
|
|
348
|
+
units = %w[B KB MB GB TB]
|
|
349
|
+
i = 0
|
|
350
|
+
size = bytes.to_f
|
|
351
|
+
while size >= 1024 && i < units.length - 1
|
|
352
|
+
size /= 1024
|
|
353
|
+
i += 1
|
|
354
|
+
end
|
|
355
|
+
format("%.1f%s", size, units[i])
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def note(bytes)
|
|
359
|
+
bytes ? " (#{human(bytes)})" : ""
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def path_bytes(path)
|
|
363
|
+
return File.size(path) if File.file?(path)
|
|
364
|
+
|
|
365
|
+
total = 0
|
|
366
|
+
Find.find(path.to_s) do |p|
|
|
367
|
+
st = File.lstat(p)
|
|
368
|
+
total += st.size if st.file?
|
|
369
|
+
rescue Errno::ENOENT
|
|
370
|
+
next
|
|
371
|
+
end
|
|
372
|
+
total
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
module Spinner
|
|
377
|
+
module_function
|
|
378
|
+
|
|
379
|
+
def with_spinner(msg)
|
|
380
|
+
# msg is a present-tense progress label; skip it under --log-file so
|
|
381
|
+
# the file only ever gets each call site's past-tense completion line.
|
|
382
|
+
# Two Spinner modes: with_spinner animates for short ops; with_progress
|
|
383
|
+
# prints a single line and does not redraw — use for chatty hooks.
|
|
384
|
+
return yield if LogFile.enabled?
|
|
385
|
+
|
|
386
|
+
unless $stdout.tty?
|
|
387
|
+
puts "#{Ctree::Log.prefix}#{msg}"
|
|
388
|
+
return yield
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
start_time = Time.now
|
|
392
|
+
state_mutex = Mutex.new
|
|
393
|
+
done = false
|
|
394
|
+
result = nil
|
|
395
|
+
|
|
396
|
+
spinner_thread = Thread.new do
|
|
397
|
+
spinner_idx = 0
|
|
398
|
+
until state_mutex.synchronize { done }
|
|
399
|
+
elapsed = (Time.now - start_time).to_i
|
|
400
|
+
line = format("%s%s (%s) %ds",
|
|
401
|
+
Ctree::Log.prefix,
|
|
402
|
+
msg,
|
|
403
|
+
SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.length],
|
|
404
|
+
elapsed)
|
|
405
|
+
print "\r\e[K#{line}"
|
|
406
|
+
$stdout.flush
|
|
407
|
+
spinner_idx += 1
|
|
408
|
+
sleep 0.1
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
begin
|
|
413
|
+
result = yield
|
|
414
|
+
ensure
|
|
415
|
+
state_mutex.synchronize { done = true }
|
|
416
|
+
spinner_thread.join
|
|
417
|
+
print "\r\e[K"
|
|
418
|
+
$stdout.flush
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
result
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Like with_spinner but without the rotating frame / elapsed redraw:
|
|
425
|
+
# prints a single stable progress line before yielding, then yields
|
|
426
|
+
# with stdout left alone. Use this for blocks whose child process
|
|
427
|
+
# writes to the same TTY (e.g. post-update hooks): the \r\e[K redraw
|
|
428
|
+
# loop would interleave with and corrupt that output.
|
|
429
|
+
def with_progress(msg)
|
|
430
|
+
return yield if LogFile.enabled?
|
|
431
|
+
|
|
432
|
+
# Plain puts matches with_spinner's non-TTY output so captured/CI
|
|
433
|
+
# output is unchanged: one line, same text, same prefix.
|
|
434
|
+
puts "#{Ctree::Log.prefix}#{msg}"
|
|
435
|
+
yield
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
module Prompt
|
|
440
|
+
module_function
|
|
441
|
+
|
|
442
|
+
def read_line(prompt)
|
|
443
|
+
if HAS_READLINE
|
|
444
|
+
Readline.readline(prompt, false)
|
|
445
|
+
else
|
|
446
|
+
print prompt
|
|
447
|
+
$stdout.flush
|
|
448
|
+
line = $stdin.gets
|
|
449
|
+
line&.chomp
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Centralized yes/no confirmation. `default` is :yes, :no, or nil (no
|
|
454
|
+
# bracketed default — the prompt text must ask the user to type "yes").
|
|
455
|
+
# Under force: true, skips stdin and returns the assumed answer silently.
|
|
456
|
+
def confirm(message, default:, force: false)
|
|
457
|
+
return default == :yes || default.nil? if force
|
|
458
|
+
|
|
459
|
+
logging = LogFile.enabled?
|
|
460
|
+
if logging
|
|
461
|
+
Scroller.pause
|
|
462
|
+
LogFile.write("PROMPT: #{message}")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
raw = read_line("#{Ctree::Log.prefix}#{message}")
|
|
466
|
+
result = if default.nil?
|
|
467
|
+
raw.to_s.gsub(/[\x00-\x1f\x7f]/, "").strip == "yes"
|
|
468
|
+
else
|
|
469
|
+
answer = raw.to_s.gsub(/[\x00-\x1f\x7f]/, "").strip.downcase
|
|
470
|
+
if default == :yes
|
|
471
|
+
answer.empty? || answer == "y" || answer == "yes"
|
|
472
|
+
else
|
|
473
|
+
answer == "y" || answer == "yes"
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
if logging
|
|
478
|
+
answer_note = raw.to_s.empty? ? "(empty, default)" : raw
|
|
479
|
+
LogFile.write("ANSWER: #{answer_note} -> #{result ? "yes" : "no"}")
|
|
480
|
+
Scroller.resume
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
result
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Generic prompt for any .env variable. Prints key=value as a log line
|
|
487
|
+
# then prompts on a short second line — avoids Readline redraw artifacts
|
|
488
|
+
# caused by prompts longer than the terminal width.
|
|
489
|
+
def for_env_var_change(key, current_value, worktree_values: {}, force: false)
|
|
490
|
+
console_header = ["#{Ctree::Log.prefix}#{key}=#{current_value}"]
|
|
491
|
+
if worktree_values.any?
|
|
492
|
+
pad = worktree_values.keys.map(&:length).max
|
|
493
|
+
worktree_values.each { |wt, val| console_header << " #{wt.ljust(pad)}: #{val}" }
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
logging = LogFile.enabled?
|
|
497
|
+
if logging
|
|
498
|
+
return current_value if force
|
|
499
|
+
sibling_note = worktree_values.any? ? " (worktree values: #{worktree_values.map { |wt, val| "#{wt}=#{val}" }.join(", ")})" : ""
|
|
500
|
+
Scroller.pause
|
|
501
|
+
puts console_header.join("\n")
|
|
502
|
+
LogFile.write("PROMPT: #{key}=#{current_value}#{sibling_note} (enter to keep, or type a new value):")
|
|
503
|
+
else
|
|
504
|
+
puts console_header.join("\n")
|
|
505
|
+
return current_value if force
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
raw = read_line(" (enter to keep, or type a new value): ")
|
|
509
|
+
result = if raw.nil?
|
|
510
|
+
current_value
|
|
511
|
+
else
|
|
512
|
+
answer = raw.gsub(/[\x00-\x1f\x7f]/, "").strip
|
|
513
|
+
answer.empty? ? current_value : answer
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
if logging
|
|
517
|
+
answer_note = raw.to_s.empty? ? "(empty, kept #{result})" : result
|
|
518
|
+
LogFile.write("ANSWER: #{answer_note}")
|
|
519
|
+
Scroller.resume
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
result
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
module Volume
|
|
527
|
+
RSYNC_IMAGE = "ctree-rsync:1"
|
|
528
|
+
|
|
529
|
+
module_function
|
|
530
|
+
|
|
531
|
+
def ensure_rsync_image!
|
|
532
|
+
_, _, st = Sh.capture3("docker", "image", "inspect", RSYNC_IMAGE)
|
|
533
|
+
return if st.success?
|
|
534
|
+
|
|
535
|
+
_, err, build_st = Spinner.with_spinner("building ctree-rsync image") do
|
|
536
|
+
Sh.capture3(
|
|
537
|
+
"docker", "build", "-t", RSYNC_IMAGE, "-",
|
|
538
|
+
stdin_data: "FROM alpine:3\nRUN apk add --no-cache rsync\n"
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
Log.die "failed to build ctree-rsync image: #{err.strip}" unless build_st.success?
|
|
542
|
+
Log.info "built ctree-rsync image"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def copy_with_progress(src_vol, tgt_vol)
|
|
546
|
+
# The same container that runs cp -a also samples /to size every 1s
|
|
547
|
+
# and emits a PROGRESS line, so we get real byte-level progress
|
|
548
|
+
# without spawning extra docker invocations.
|
|
549
|
+
script = <<~SH
|
|
550
|
+
total=$(du -sb /from 2>/dev/null | awk '{print $1}')
|
|
551
|
+
echo "TOTAL:$total"
|
|
552
|
+
( cd /from && cp -a . /to/ ) &
|
|
553
|
+
cp_pid=$!
|
|
554
|
+
while kill -0 "$cp_pid" 2>/dev/null; do
|
|
555
|
+
sleep 1
|
|
556
|
+
current=$(du -sb /to 2>/dev/null | awk '{print $1}')
|
|
557
|
+
echo "PROGRESS:$current"
|
|
558
|
+
done
|
|
559
|
+
wait "$cp_pid"
|
|
560
|
+
SH
|
|
561
|
+
|
|
562
|
+
cmd = [
|
|
563
|
+
"docker", "run", "--rm",
|
|
564
|
+
"-v", "#{src_vol}:/from:ro",
|
|
565
|
+
"-v", "#{tgt_vol}:/to",
|
|
566
|
+
"alpine", "sh", "-c", script
|
|
567
|
+
]
|
|
568
|
+
|
|
569
|
+
start_time = Time.now
|
|
570
|
+
total_bytes = nil
|
|
571
|
+
current_bytes = 0
|
|
572
|
+
err_buf = +""
|
|
573
|
+
exit_status = nil
|
|
574
|
+
state_mutex = Mutex.new
|
|
575
|
+
done = false
|
|
576
|
+
|
|
577
|
+
live_progress = $stdout.tty? && !LogFile.enabled?
|
|
578
|
+
puts "copying #{src_vol} -> #{tgt_vol}" unless $stdout.tty?
|
|
579
|
+
|
|
580
|
+
Sh.popen3(*cmd) do |_stdin, stdout, stderr, wait_thr|
|
|
581
|
+
err_thread = Thread.new { err_buf << stderr.read.to_s }
|
|
582
|
+
|
|
583
|
+
render_thread = Thread.new do
|
|
584
|
+
spinner_idx = 0
|
|
585
|
+
until state_mutex.synchronize { done }
|
|
586
|
+
cur, tot = state_mutex.synchronize { [current_bytes, total_bytes] }
|
|
587
|
+
elapsed = (Time.now - start_time).to_i
|
|
588
|
+
pct = (tot && tot > 0) ? [(cur.to_f / tot * 100).round, 100].min : 0
|
|
589
|
+
if live_progress
|
|
590
|
+
print format("\r\e[K#{Ctree::Log.prefix}copying %s -> %s %d%% (%s) %ds",
|
|
591
|
+
src_vol, tgt_vol, pct,
|
|
592
|
+
SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.length], elapsed)
|
|
593
|
+
$stdout.flush
|
|
594
|
+
end
|
|
595
|
+
spinner_idx += 1
|
|
596
|
+
sleep 0.1
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
stdout.each_line do |raw|
|
|
601
|
+
line = raw.strip
|
|
602
|
+
if line.start_with?("TOTAL:")
|
|
603
|
+
state_mutex.synchronize { total_bytes = line.sub("TOTAL:", "").to_i }
|
|
604
|
+
elsif line.start_with?("PROGRESS:")
|
|
605
|
+
state_mutex.synchronize { current_bytes = line.sub("PROGRESS:", "").to_i }
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
state_mutex.synchronize { done = true }
|
|
610
|
+
render_thread.join
|
|
611
|
+
err_thread.join
|
|
612
|
+
exit_status = wait_thr.value
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
if live_progress
|
|
616
|
+
print "\r\e[K"
|
|
617
|
+
$stdout.flush
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
if exit_status.success?
|
|
621
|
+
completion = "copied #{src_vol} -> #{tgt_vol}#{Sizes.note(total_bytes)}"
|
|
622
|
+
# The log file is user-requested output: the completion line is always
|
|
623
|
+
# recorded there, regardless of log_level (which only governs the console).
|
|
624
|
+
LogFile.write(completion) if LogFile.enabled?
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
[exit_status, err_buf, total_bytes]
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def rsync_with_progress(src_vol, tgt_vol)
|
|
631
|
+
cmd = [
|
|
632
|
+
"docker", "run", "--rm",
|
|
633
|
+
"-v", "#{src_vol}:/from:ro",
|
|
634
|
+
"-v", "#{tgt_vol}:/to",
|
|
635
|
+
RSYNC_IMAGE, "sh", "-c", "rsync -a --delete --stats /from/ /to/"
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
out, err, st = Spinner.with_spinner("updating #{src_vol} -> #{tgt_vol}") do
|
|
639
|
+
Sh.capture3(*cmd)
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
total_bytes = nil
|
|
643
|
+
out.each_line do |line|
|
|
644
|
+
if line =~ /\ATotal file size:\s+([\d,]+)\s+bytes/
|
|
645
|
+
total_bytes = $1.tr(",", "").to_i
|
|
646
|
+
break
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
if st.success?
|
|
651
|
+
line = "updated #{src_vol} -> #{tgt_vol}#{Sizes.note(total_bytes)}"
|
|
652
|
+
# One destination per run keeps the line single (CTR-055): always in the
|
|
653
|
+
# log file, or on the console in debug mode when there is no log file.
|
|
654
|
+
LogFile.enabled? ? LogFile.write(line) : Log.debug(line)
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
[st, err, total_bytes]
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
module Images
|
|
662
|
+
module_function
|
|
663
|
+
|
|
664
|
+
# Tags every source-project image (<source_project>-<service>) to the
|
|
665
|
+
# corresponding <target_project>-<service> name. Docker tags are metadata
|
|
666
|
+
# pointers, so this is instant and lets the worktree's `docker compose up`
|
|
667
|
+
# skip rebuilding identical images. Re-running picks up freshly built
|
|
668
|
+
# source layers (e.g. after rebuilding source images). Returns the list of
|
|
669
|
+
# [src_img, tgt_img] pairs that were tagged.
|
|
670
|
+
def tag_to_target(source_project, target_project)
|
|
671
|
+
images_out, _, _ = Sh.capture3("docker", "images",
|
|
672
|
+
"--format", "{{.Repository}}",
|
|
673
|
+
"--filter", "label=com.docker.compose.project=#{source_project}",
|
|
674
|
+
"--filter", "reference=#{source_project}-*")
|
|
675
|
+
tagged = []
|
|
676
|
+
images_out.lines.map(&:strip).reject(&:empty?).each do |src_img|
|
|
677
|
+
suffix = src_img.sub("#{source_project}-", "")
|
|
678
|
+
tgt_img = "#{target_project}-#{suffix}"
|
|
679
|
+
_, _, st = Sh.capture3("docker", "tag", src_img, tgt_img)
|
|
680
|
+
if st.success?
|
|
681
|
+
Log.debug "tagged #{src_img} -> #{tgt_img}"
|
|
682
|
+
tagged << [src_img, tgt_img]
|
|
683
|
+
end
|
|
684
|
+
end
|
|
685
|
+
tagged
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Wraps the darwin clonefile(2) syscall via FFI so ctree can clone an
|
|
690
|
+
# entire directory tree with a single syscall — dramatically faster than
|
|
691
|
+
# `cp -c -R`, which calls clonefile per file. On non-darwin platforms
|
|
692
|
+
# (or if libSystem can't be loaded), `available?` returns false and
|
|
693
|
+
# callers fall back to the next strategy.
|
|
694
|
+
module Clonefile
|
|
695
|
+
module_function
|
|
696
|
+
|
|
697
|
+
begin
|
|
698
|
+
if RUBY_PLATFORM =~ /darwin/
|
|
699
|
+
require "fiddle"
|
|
700
|
+
require "fiddle/import"
|
|
701
|
+
|
|
702
|
+
module Lib
|
|
703
|
+
extend Fiddle::Importer
|
|
704
|
+
dlload "libSystem.dylib"
|
|
705
|
+
extern "int clonefile(const char *src, const char *dst, unsigned int flags)"
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
AVAILABLE = true
|
|
709
|
+
else
|
|
710
|
+
AVAILABLE = false
|
|
711
|
+
end
|
|
712
|
+
rescue LoadError, Fiddle::DLError, NameError
|
|
713
|
+
AVAILABLE = false
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def available?
|
|
717
|
+
AVAILABLE
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Single-syscall whole-tree clone. Returns true on success, false
|
|
721
|
+
# otherwise. Target must NOT exist (clonefile fails with EEXIST if it
|
|
722
|
+
# does); caller is responsible for that precondition.
|
|
723
|
+
# Note: clonefile follows symlinks in src regardless of flag 0x0002 on
|
|
724
|
+
# some macOS versions. Callers should handle symlinks via File.symlink?
|
|
725
|
+
# before calling clone() to avoid dereferencing.
|
|
726
|
+
def clone(src, tgt)
|
|
727
|
+
return false unless AVAILABLE
|
|
728
|
+
Lib.clonefile(src.to_s, tgt.to_s, 0) == 0
|
|
729
|
+
end
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
module ComposeOverride
|
|
733
|
+
module_function
|
|
734
|
+
|
|
735
|
+
# Pure check. Returns {ok: [...], fixable: [...], unfixable: [...]}.
|
|
736
|
+
# ok = suffixes with correct external reference already present
|
|
737
|
+
# fixable = suffixes missing from override but source volume exists in Docker
|
|
738
|
+
# unfixable = suffixes missing from override and source volume not in Docker
|
|
739
|
+
def audit(target_path:, override_rel:, share_volumes:, source_project:, source_volumes:)
|
|
740
|
+
override_path = Pathname.new(target_path) / override_rel
|
|
741
|
+
unless override_path.file?
|
|
742
|
+
return { ok: [], fixable: [], unfixable: share_volumes.dup }
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
begin
|
|
746
|
+
raw = File.read(override_path.to_s)
|
|
747
|
+
stream = Psych.parse_stream(raw)
|
|
748
|
+
doc_node = stream.children.first
|
|
749
|
+
return { ok: [], fixable: [], unfixable: share_volumes.dup } unless doc_node
|
|
750
|
+
root = doc_node.children.first
|
|
751
|
+
return { ok: [], fixable: [], unfixable: share_volumes.dup } unless root.is_a?(Psych::Nodes::Mapping)
|
|
752
|
+
|
|
753
|
+
existing = extract_existing_volumes(root, source_project)
|
|
754
|
+
|
|
755
|
+
ok = []
|
|
756
|
+
missing = []
|
|
757
|
+
share_volumes.each do |suffix|
|
|
758
|
+
e = existing[suffix]
|
|
759
|
+
if e && e[:external] && e[:name] == "#{source_project}_#{suffix}"
|
|
760
|
+
ok << suffix
|
|
761
|
+
else
|
|
762
|
+
missing << suffix
|
|
763
|
+
end
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
fixable, unfixable = missing.partition { |s| source_volumes.include?("#{source_project}_#{s}") }
|
|
767
|
+
{ ok: ok, fixable: fixable, unfixable: unfixable }
|
|
768
|
+
rescue Psych::SyntaxError => e
|
|
769
|
+
Log.warn_ "could not parse #{override_rel}: #{e.message}; skipping shared volume validation"
|
|
770
|
+
{ ok: [], fixable: [], unfixable: share_volumes.dup }
|
|
771
|
+
end
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
# Returns true when all share_volumes have correct external refs (no Docker call).
|
|
775
|
+
def valid?(target_path:, override_rel:, share_volumes:, source_project:)
|
|
776
|
+
return true if override_rel.empty? || share_volumes.empty?
|
|
777
|
+
all_expected = share_volumes.map { |s| "#{source_project}_#{s}" }
|
|
778
|
+
result = audit(
|
|
779
|
+
target_path: target_path, override_rel: override_rel,
|
|
780
|
+
share_volumes: share_volumes, source_project: source_project,
|
|
781
|
+
source_volumes: all_expected
|
|
782
|
+
)
|
|
783
|
+
result[:fixable].empty? && result[:unfixable].empty?
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
# Warn-only, no prompts, no file writes. Called by ctree create.
|
|
787
|
+
def check(target_path:, override_rel:, share_volumes:, source_project:, source_volumes:)
|
|
788
|
+
return if override_rel.empty? || share_volumes.empty?
|
|
789
|
+
|
|
790
|
+
result = audit(
|
|
791
|
+
target_path: target_path, override_rel: override_rel,
|
|
792
|
+
share_volumes: share_volumes, source_project: source_project,
|
|
793
|
+
source_volumes: source_volumes
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
result[:unfixable].each do |suffix|
|
|
797
|
+
Log.warn_ "share volume '#{suffix}' missing from #{override_rel} " \
|
|
798
|
+
"and source volume #{source_project}_#{suffix} not found in Docker"
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
if result[:fixable].any?
|
|
802
|
+
n = result[:fixable].size
|
|
803
|
+
Log.warn_ "#{n} share volume#{n == 1 ? "" : "s"} missing external reference " \
|
|
804
|
+
"in #{override_rel}: #{result[:fixable].join(", ")}"
|
|
805
|
+
Log.warn_ "run `ctree compose-config fix` to fix"
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# Fix without prompts. Called by ctree compose-config fix.
|
|
810
|
+
# Returns true if any fixes were applied, false otherwise.
|
|
811
|
+
def fix(target_path:, override_rel:, share_volumes:, source_project:, source_volumes:)
|
|
812
|
+
override_path = Pathname.new(target_path) / override_rel
|
|
813
|
+
unless override_path.file?
|
|
814
|
+
Log.warn_ "#{override_rel} not found; nothing to fix"
|
|
815
|
+
return false
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
result = audit(
|
|
819
|
+
target_path: target_path, override_rel: override_rel,
|
|
820
|
+
share_volumes: share_volumes, source_project: source_project,
|
|
821
|
+
source_volumes: source_volumes
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
result[:unfixable].each do |suffix|
|
|
825
|
+
src_name = "#{source_project}_#{suffix}"
|
|
826
|
+
Log.warn_ "source volume #{src_name} not found in Docker; skipping #{suffix}"
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
return false if result[:fixable].empty?
|
|
830
|
+
|
|
831
|
+
begin
|
|
832
|
+
raw = File.read(override_path.to_s)
|
|
833
|
+
stream = Psych.parse_stream(raw)
|
|
834
|
+
doc_node = stream.children.first
|
|
835
|
+
return false unless doc_node
|
|
836
|
+
root = doc_node.children.first
|
|
837
|
+
return false unless root.is_a?(Psych::Nodes::Mapping)
|
|
838
|
+
|
|
839
|
+
volumes_node = find_or_create_volumes_node(root)
|
|
840
|
+
unless volumes_node.is_a?(Psych::Nodes::Mapping)
|
|
841
|
+
Log.warn_ "#{override_rel}: 'volumes' is not a mapping; cannot fix"
|
|
842
|
+
return false
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
result[:fixable].each do |suffix|
|
|
846
|
+
src_name = "#{source_project}_#{suffix}"
|
|
847
|
+
entry = Psych::Nodes::Mapping.new
|
|
848
|
+
entry.children << Psych::Nodes::Scalar.new("external")
|
|
849
|
+
entry.children << Psych::Nodes::Scalar.new("true", nil, nil, true, false)
|
|
850
|
+
entry.children << Psych::Nodes::Scalar.new("name")
|
|
851
|
+
entry.children << Psych::Nodes::Scalar.new(src_name)
|
|
852
|
+
volumes_node.children << Psych::Nodes::Scalar.new(suffix)
|
|
853
|
+
volumes_node.children << entry
|
|
854
|
+
Log.debug "fixed #{suffix} -> #{src_name} in #{override_rel}"
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
File.write(override_path.to_s, stream.to_yaml)
|
|
858
|
+
n = result[:fixable].size
|
|
859
|
+
Log.info "fixed #{n} share volume#{n == 1 ? "" : "s"} in #{override_rel}: #{result[:fixable].join(", ")}"
|
|
860
|
+
true
|
|
861
|
+
rescue Psych::SyntaxError => e
|
|
862
|
+
Log.warn_ "could not parse #{override_rel}: #{e.message}; fix aborted"
|
|
863
|
+
false
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Internal: extract volume entries from a root mapping node.
|
|
868
|
+
# Returns a hash of suffix => {external:, name:}.
|
|
869
|
+
def extract_existing_volumes(root, _source_project = nil)
|
|
870
|
+
vol_key_idx = find_volumes_key_idx(root)
|
|
871
|
+
existing = {}
|
|
872
|
+
if vol_key_idx
|
|
873
|
+
volumes_node = root.children[vol_key_idx + 1]
|
|
874
|
+
if volumes_node.is_a?(Psych::Nodes::Mapping)
|
|
875
|
+
volumes_node.children.each_slice(2) do |key_node, val_node|
|
|
876
|
+
next unless key_node.is_a?(Psych::Nodes::Scalar)
|
|
877
|
+
suffix = key_node.value
|
|
878
|
+
next unless val_node.is_a?(Psych::Nodes::Mapping)
|
|
879
|
+
ext = false
|
|
880
|
+
name = nil
|
|
881
|
+
val_node.children.each_slice(2) do |k, v|
|
|
882
|
+
next unless k.is_a?(Psych::Nodes::Scalar) && v.is_a?(Psych::Nodes::Scalar)
|
|
883
|
+
ext = true if k.value == "external" && v.value == "true"
|
|
884
|
+
name = v.value if k.value == "name"
|
|
885
|
+
end
|
|
886
|
+
existing[suffix] = { external: ext, name: name }
|
|
887
|
+
end
|
|
888
|
+
end
|
|
889
|
+
end
|
|
890
|
+
existing
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
# Internal: find the index of the "volumes" key in a root mapping node.
|
|
894
|
+
def find_volumes_key_idx(root)
|
|
895
|
+
vol_key_idx = nil
|
|
896
|
+
root.children.each_with_index do |child, i|
|
|
897
|
+
if i.even? && child.is_a?(Psych::Nodes::Scalar) && child.value == "volumes"
|
|
898
|
+
vol_key_idx = i
|
|
899
|
+
break
|
|
900
|
+
end
|
|
901
|
+
end
|
|
902
|
+
vol_key_idx
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
# Internal: find or create the volumes mapping node under root.
|
|
906
|
+
def find_or_create_volumes_node(root)
|
|
907
|
+
vol_key_idx = find_volumes_key_idx(root)
|
|
908
|
+
if vol_key_idx
|
|
909
|
+
root.children[vol_key_idx + 1]
|
|
910
|
+
else
|
|
911
|
+
m = Psych::Nodes::Mapping.new
|
|
912
|
+
root.children << Psych::Nodes::Scalar.new("volumes")
|
|
913
|
+
root.children << m
|
|
914
|
+
m
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
private_class_method :extract_existing_volumes, :find_volumes_key_idx, :find_or_create_volumes_node
|
|
919
|
+
end
|
|
920
|
+
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
require_relative "ctree/version"
|
|
924
|
+
require_relative "ctree/config"
|
|
925
|
+
require_relative "ctree/cli"
|
|
926
|
+
require_relative "ctree/create"
|
|
927
|
+
require_relative "ctree/rebase"
|
|
928
|
+
require_relative "ctree/delete"
|
|
929
|
+
require_relative "ctree/domain"
|
|
930
|
+
require_relative "ctree/list"
|
|
931
|
+
require_relative "ctree/update"
|
|
932
|
+
require_relative "ctree/free"
|
|
933
|
+
require_relative "ctree/env_cmd"
|
|
934
|
+
require_relative "ctree/compose_config_cmd"
|
|
935
|
+
require_relative "ctree/sync"
|