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.
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"