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.
@@ -0,0 +1,714 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ctree
4
+ module Create
5
+ module_function
6
+
7
+ def run(name:, branch:, config_path: nil, 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
+ # A linked worktree's --git-dir (.../.git/worktrees/<name>) differs from
17
+ # its --git-common-dir (the shared .git); the main checkout's are the
18
+ # same path. Refuse to create a worktree of a worktree.
19
+ git_dir_out, _, gd_st = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--git-dir")
20
+ common_dir_out, _, gc_st = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--git-common-dir")
21
+ if gd_st.success? && gc_st.success?
22
+ git_dir = Pathname.new(git_dir_out.strip)
23
+ git_dir = (source_root / git_dir) unless git_dir.absolute?
24
+ common_dir = Pathname.new(common_dir_out.strip)
25
+ common_dir = (source_root / common_dir) unless common_dir.absolute?
26
+ if git_dir.realpath != common_dir.realpath
27
+ Log.die "ctree create must be run from a source repo, not from a worktree"
28
+ end
29
+ end
30
+
31
+ source_basename = source_root.basename.to_s
32
+ parent_dir = source_root.parent
33
+ target_path = parent_dir / name
34
+
35
+ Log.die "target path already exists: #{target_path}" if target_path.exist?
36
+
37
+ Log.info "creating worktree #{name} from #{source_basename}"
38
+
39
+ # === verify source is on default branch ===
40
+
41
+ default_branch = Git.detect_default_branch(source_root)
42
+ current_branch_out, _, cb_st = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--abbrev-ref", "HEAD")
43
+ if cb_st.success?
44
+ current_branch = current_branch_out.strip
45
+ unless current_branch == default_branch
46
+ Log.warn_ "source repo is on branch '#{current_branch}', not '#{default_branch}'"
47
+
48
+ dirty_out, _, _ = Sh.capture3("git", "-C", source_root.to_s, "status", "--porcelain")
49
+ has_changes = !dirty_out.strip.empty?
50
+
51
+ if has_changes
52
+ if Prompt.confirm("stash uncommitted changes and switch to #{default_branch}? [Y/n]:", default: :yes, force: force)
53
+ _, stash_err, stash_st = Sh.capture3("git", "-C", source_root.to_s, "stash")
54
+ Log.die "git stash failed: #{stash_err.strip}" unless stash_st.success?
55
+ Log.info "stashed uncommitted changes"
56
+ _, co_err, co_st = Sh.capture3("git", "-C", source_root.to_s, "checkout", default_branch)
57
+ Log.die "git checkout #{default_branch} failed: #{co_err.strip}" unless co_st.success?
58
+ Log.info "switched source to #{default_branch}"
59
+ else
60
+ Log.die "please checkout #{default_branch} in the source repo and re-run ctree"
61
+ end
62
+ else
63
+ if Prompt.confirm("switch source to #{default_branch} branch? [Y/n]:", default: :yes, force: force)
64
+ _, co_err, co_st = Sh.capture3("git", "-C", source_root.to_s, "checkout", default_branch)
65
+ Log.die "git checkout #{default_branch} failed: #{co_err.strip}" unless co_st.success?
66
+ Log.info "switched source to #{default_branch}"
67
+ else
68
+ Log.die "please checkout #{default_branch} in the source repo and re-run ctree"
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ config = if config_path
75
+ Config.load_with_override(config_path)
76
+ else
77
+ Config.load(source_root)
78
+ end
79
+ Log.debug_mode = config[:log_level] == "debug"
80
+ Log.log_prefix = config[:log_prefix]
81
+ share_volumes = config[:share_volumes]
82
+ empty_volumes = config[:empty_volumes]
83
+ exclude = config[:exclude]
84
+ env_filename = config[:env_filename]
85
+
86
+ # === resolve compose project names ===
87
+
88
+ source_env_vars = EnvFile.parse(source_root / env_filename)
89
+ env_compose_project = source_env_vars["COMPOSE_PROJECT_NAME"]
90
+ if env_compose_project && !env_compose_project.empty?
91
+ source_project = env_compose_project
92
+ source_project_origin = "from #{source_root / env_filename} (COMPOSE_PROJECT_NAME)"
93
+ else
94
+ source_project = Naming.sanitize_compose_project_name(source_basename)
95
+ source_project_origin = "from source dir basename (no COMPOSE_PROJECT_NAME in #{env_filename})"
96
+ end
97
+ target_project = Naming.sanitize_compose_project_name(name)
98
+
99
+ Log.debug "source project (compose): #{source_project} [#{source_project_origin}]"
100
+ Log.debug "target project (compose): #{target_project}"
101
+ Log.debug "identified source volumes by prefix: #{source_project}_"
102
+
103
+ # === pre-flight ===
104
+
105
+ _, _, status = Sh.capture3("git", "-C", source_root.to_s, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
106
+ branch_exists = status.success?
107
+
108
+ _, _, status = Sh.capture3("docker", "info")
109
+ Log.die "docker not available (is the daemon running?)" unless status.success?
110
+
111
+ ps_out, _, status = Sh.capture3(
112
+ "docker", "ps",
113
+ "--filter", "label=com.docker.compose.project=#{source_project}",
114
+ "--quiet"
115
+ )
116
+ Log.die "docker ps failed" unless status.success?
117
+ unless ps_out.strip.empty?
118
+ Log.warn_ "source compose stack has running containers"
119
+ if Prompt.confirm("stop source containers before creating worktree? [Y/n]:", default: :yes, force: force)
120
+ _, err, st = Spinner.with_spinner("stopping source compose stack") do
121
+ Sh.capture3("docker", "compose", "-p", source_project, "down")
122
+ end
123
+ if st.success?
124
+ Log.info "stopped source compose stack"
125
+ else
126
+ Log.die "failed to stop source compose stack: #{err.strip}"
127
+ end
128
+ else
129
+ Log.die "source containers still running; please stop them and re-run ctree"
130
+ end
131
+ end
132
+
133
+ # === register worktree + clone all source content ===
134
+ # git worktree add --no-checkout creates an empty target dir with a
135
+ # .git FILE pointer but skips the file checkout. We then clone every
136
+ # top-level item from source into target (excluding .git, which is
137
+ # already a FILE from step 1). This brings tracked AND gitignored
138
+ # content (node_modules, build artifacts, plugin lock files, etc.)
139
+ # in O(N top-level items) clonefile(2) calls — each call clones a
140
+ # whole subtree. On macOS APFS, each call is instant CoW. On Linux,
141
+ # cp --reflink=auto gives CoW on btrfs/xfs and falls back on ext4.
142
+
143
+ worktree_args = if branch_exists
144
+ ["worktree", "add", "--no-checkout", target_path.to_s, branch]
145
+ else
146
+ ["worktree", "add", "--no-checkout", "-b", branch, target_path.to_s]
147
+ end
148
+ _, wt_err, wt_st = Sh.capture3("git", "-C", source_root.to_s, *worktree_args)
149
+ Log.die "git worktree add failed: #{wt_err.strip}" unless wt_st.success?
150
+ Log.debug "created worktree at #{target_path}"
151
+ Log.debug branch_exists ? "checked out existing branch #{branch}" : "checked out new branch #{branch}"
152
+ Log.info "created worktree #{name} (branch: #{branch}) from #{source_basename}"
153
+
154
+ items = Dir.glob(File.join(source_root.to_s, "*"), File::FNM_DOTMATCH)
155
+ .reject { |p| %w[. .. .git .ctree].include?(File.basename(p)) }
156
+ excluded = []
157
+ if exclude.any?
158
+ items, excluded = items.partition { |p| !exclude.include?(File.basename(p)) }
159
+ excluded.each { |p| Log.debug "skipped #{File.basename(p)} (exclude)" }
160
+ end
161
+ total = items.size
162
+ failed_items = []
163
+
164
+ # On non-TTY the spinner doesn't run, so log the start explicitly.
165
+ # On TTY the spinner itself is the start indicator — skip the static line
166
+ # to avoid showing both "copying source content" and the spinner line.
167
+ $stdout.puts "copying source content" unless $stdout.tty?
168
+
169
+ state_mutex = Mutex.new
170
+ done_count = 0
171
+ clone_done = false
172
+ clone_start = Time.now
173
+ source_size_bytes = nil
174
+
175
+ # Measure total size of cloned items in parallel so it's ready by the
176
+ # time cloning finishes (cloning typically takes much longer than du).
177
+ du_thread = Thread.new do
178
+ out, _, st = Sh.capture3("du", "-sk", *items)
179
+ source_size_bytes = out.lines.sum { |l| l.split("\t").first.to_i } * 1024 if st.success?
180
+ end
181
+
182
+ render_thread = if $stdout.tty? && !LogFile.enabled?
183
+ Thread.new do
184
+ spinner_idx = 0
185
+ until state_mutex.synchronize { clone_done }
186
+ n = state_mutex.synchronize { done_count }
187
+ elapsed = (Time.now - clone_start).to_i
188
+ pct = total > 0 ? (n * 100 / total) : 0
189
+ print format("\r\e[K#{Ctree::Log.prefix}copying source content %d%% (%s) %ds",
190
+ pct, SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.length], elapsed)
191
+ $stdout.flush
192
+ spinner_idx += 1
193
+ sleep 0.1
194
+ end
195
+ end
196
+ end
197
+
198
+ items.each do |src_child|
199
+ base = File.basename(src_child)
200
+ tgt_child = (target_path / base).to_s
201
+
202
+ item_ok = if File.symlink?(src_child)
203
+ # clonefile(2) follows symlinks in src regardless of flags on this
204
+ # macOS version, producing a regular file. Handle symlinks explicitly.
205
+ begin
206
+ File.symlink(File.readlink(src_child), tgt_child)
207
+ true
208
+ rescue SystemCallError
209
+ false
210
+ end
211
+ elsif Clonefile.available? && Clonefile.clone(src_child, tgt_child)
212
+ true
213
+ else
214
+ _, _, st = Sh.capture3("cp", "--reflink=auto", "-R", src_child, tgt_child)
215
+ st.success?
216
+ end
217
+
218
+ failed_items << base unless item_ok
219
+ state_mutex.synchronize { done_count += 1 }
220
+ end
221
+
222
+ state_mutex.synchronize { clone_done = true }
223
+
224
+ # Stop the spinner first so the user isn't staring at a frozen 100%
225
+ # bar while we wait for du to finish.
226
+ if render_thread
227
+ render_thread.join
228
+ print "\r\e[K"
229
+ $stdout.flush
230
+ end
231
+
232
+ # Give du up to 3s to finish. If it misses the window (shouldn't
233
+ # happen since cloning takes much longer), just omit the size.
234
+ du_thread.join(3)
235
+
236
+ details = [
237
+ source_size_bytes && Sizes.human(source_size_bytes),
238
+ excluded.any? ? "#{excluded.size} excluded" : nil,
239
+ ].compact
240
+ note = details.empty? ? "" : " (#{details.join(", ")})"
241
+ Log.info "copied source content#{note}"
242
+ failed_items.each { |b| Log.warn_ "failed to clone #{b}" }
243
+
244
+ # Sync the index, restore tracked files, and remove stale untracked files.
245
+ #
246
+ # Clonefile copies the SOURCE working tree into the new worktree. If the
247
+ # feature branch diverges from the source branch, git reports every
248
+ # differing tracked file as "modified" and every source-only file as
249
+ # "untracked". Three-step fix:
250
+ # 1. git reset HEAD — syncs the index to the branch's HEAD
251
+ # 2. git checkout HEAD . — restores tracked working-tree files to match
252
+ # 3. git clean -fd — removes untracked files that exist in the
253
+ # source but not on this branch (e.g. files
254
+ # committed to master after branching)
255
+ #
256
+ # Gitignored files (.env, docker-compose.local.ctree.yml, node_modules,
257
+ # etc.) are untouched by git checkout and git clean -fd, so the source
258
+ # content ctree copied for those paths is preserved.
259
+ sync_start = Time.now
260
+ _, _, rst = Spinner.with_spinner("syncing git index") do
261
+ Sh.capture3("git", "-C", target_path.to_s, "reset", "HEAD")
262
+ end
263
+ if rst.success?
264
+ _, co_err, co_st = Spinner.with_spinner("restoring tracked files to branch HEAD") do
265
+ Sh.capture3("git", "-C", target_path.to_s, "checkout", "HEAD", "--", ".")
266
+ end
267
+ if co_st.success?
268
+ _, cl_err, cl_st = Spinner.with_spinner("removing source-only untracked files") do
269
+ Sh.capture3("git", "-C", target_path.to_s, "clean", "-fd")
270
+ end
271
+ if cl_st.success?
272
+ Log.info "synced git index and working tree (#{(Time.now - sync_start).to_i}s)"
273
+ else
274
+ Log.warn_ "git clean -fd failed: #{cl_err.strip}; git status may show untracked files"
275
+ end
276
+ else
277
+ Log.warn_ "git checkout HEAD failed: #{co_err.strip}; git status may show unexpected changes"
278
+ end
279
+ else
280
+ Log.warn_ "git reset HEAD returned non-zero; git status may show unexpected changes"
281
+ end
282
+
283
+ # Recreate .ctree/config.yml from source after the git sync.
284
+ # .ctree is gitignored, so git checkout/clean never touches it.
285
+ # Without this step the worktree would have no .ctree/config.yml at all,
286
+ # since the hardcoded .ctree skip above prevents raw-cloning the directory.
287
+ tgt_ctree_config = target_path / ".ctree" / "config.yml"
288
+ unless tgt_ctree_config.file?
289
+ src_ctree_config = source_root / ".ctree" / "config.yml"
290
+ if src_ctree_config.file?
291
+ FileUtils.mkdir_p(tgt_ctree_config.dirname.to_s)
292
+ FileUtils.cp(src_ctree_config.to_s, tgt_ctree_config.to_s)
293
+ Log.debug "recreated .ctree/config.yml in worktree (source not git-tracked)"
294
+ end
295
+ end
296
+
297
+ # When --config was given, persist the custom config into the worktree's
298
+ # .ctree/config.yml so later commands run from inside it pick it up.
299
+ # This must happen AFTER the git-sync step above, otherwise git checkout
300
+ # HEAD -- . would overwrite it with the committed version.
301
+ if config_path
302
+ ctree_dir = target_path / ".ctree"
303
+ FileUtils.mkdir_p(ctree_dir.to_s)
304
+ FileUtils.cp(File.expand_path(config_path, Dir.pwd), (ctree_dir / "config.yml").to_s)
305
+ Log.info "using custom config #{config_path} for this worktree (persisted to .ctree/config.yml)"
306
+ end
307
+
308
+ config[:update].each do |rel|
309
+ if rel == ".ctree" || rel.start_with?(".ctree/")
310
+ Log.debug "skipped #{rel} (reserved directory managed by ctree create)"
311
+ next
312
+ end
313
+ src = source_root / rel
314
+ tgt = target_path / rel
315
+ begin
316
+ if src.directory?
317
+ FileUtils.mkdir_p(tgt.to_s)
318
+ FileUtils.cp_r(src.children.map(&:to_s), tgt.to_s)
319
+ Log.debug "copied #{rel}/ from source (create/update)"
320
+ elsif src.file?
321
+ FileUtils.cp(src.to_s, tgt.to_s)
322
+ Log.debug "copied #{rel} from source (create/update)"
323
+ end
324
+ rescue => e
325
+ Log.warn_ "update: failed to copy #{rel}: #{e.message}"
326
+ end
327
+ end
328
+
329
+ # === tag source images to target project names ===
330
+ # Docker builds per-project images tagged <project>-<service>. Without
331
+ # tagging, `docker compose up` in the new worktree rebuilds all images
332
+ # even though they are identical to the source. Tagging costs zero time
333
+ # (it is a metadata pointer) and makes subsequent dcup skip the build.
334
+ # Must happen before docker compose up --no-start so --no-build can
335
+ # resolve images.
336
+
337
+ tagged = Images.tag_to_target(source_project, target_project)
338
+ Log.info "tagged #{tagged.size} docker images" if tagged.any? && !Log.debug?
339
+
340
+ # === discover and replicate volumes ===
341
+
342
+ volume_results = []
343
+
344
+ vols_out, _, status = Sh.capture3("docker", "volume", "ls", "--format", "{{.Name}}")
345
+ Log.die "docker volume ls failed" unless status.success?
346
+
347
+ prefix_re = /\A#{Regexp.escape(source_project)}_/
348
+ source_volumes = vols_out.lines.map(&:strip).reject(&:empty?).select { |v| v =~ prefix_re }
349
+
350
+ if source_volumes.empty?
351
+ Log.debug "no source volumes found with prefix '#{source_project}_'"
352
+ end
353
+
354
+ # Rewrite override.yml to mark shared (cache) volumes as external BEFORE
355
+ # docker compose up --no-start, so Compose points them at the source
356
+ # volume rather than creating new empty ones.
357
+ override_rel = config[:compose_override_file].to_s
358
+ tgt_override = override_rel.empty? ? nil : (target_path / override_rel)
359
+ externalized_suffixes = []
360
+
361
+ if share_volumes.any?
362
+ if override_rel.empty? || tgt_override.nil?
363
+ Log.warn_ "compose_override_file not configured; shared volumes will not be externalized: #{share_volumes.join(", ")}"
364
+ elsif !tgt_override.file?
365
+ Log.warn_ "#{override_rel} not found in worktree; shared volumes will not be externalized: #{share_volumes.join(", ")}"
366
+ else
367
+ shared_keys = []
368
+ begin
369
+ stream = Psych.parse_stream(File.read(tgt_override.to_s))
370
+ doc_node = stream.children.first || begin
371
+ d = Psych::Nodes::Document.new
372
+ stream.children << d
373
+ d
374
+ end
375
+
376
+ root = doc_node.children.first
377
+ unless root.is_a?(Psych::Nodes::Mapping)
378
+ root = Psych::Nodes::Mapping.new
379
+ doc_node.children.unshift(root)
380
+ end
381
+
382
+ vol_key_idx = nil
383
+ root.children.each_with_index do |child, i|
384
+ if i.even? && child.is_a?(Psych::Nodes::Scalar) && child.value == "volumes"
385
+ vol_key_idx = i
386
+ break
387
+ end
388
+ end
389
+
390
+ volumes_node = if vol_key_idx
391
+ root.children[vol_key_idx + 1]
392
+ else
393
+ m = Psych::Nodes::Mapping.new
394
+ root.children << Psych::Nodes::Scalar.new("volumes")
395
+ root.children << m
396
+ m
397
+ end
398
+
399
+ if volumes_node.is_a?(Psych::Nodes::Mapping)
400
+ share_volumes.each do |suffix|
401
+ src_name = "#{source_project}_#{suffix}"
402
+ unless source_volumes.include?(src_name)
403
+ Log.warn_ "source volume #{src_name} not found; #{suffix} will not be shared with source"
404
+ next
405
+ end
406
+
407
+ entry = Psych::Nodes::Mapping.new
408
+ entry.children << Psych::Nodes::Scalar.new("external")
409
+ entry.children << Psych::Nodes::Scalar.new("true", nil, nil, true, false)
410
+ entry.children << Psych::Nodes::Scalar.new("name")
411
+ entry.children << Psych::Nodes::Scalar.new(src_name)
412
+
413
+ volumes_node.children << Psych::Nodes::Scalar.new(suffix)
414
+ volumes_node.children << entry
415
+ shared_keys << suffix
416
+ end
417
+
418
+ if shared_keys.any?
419
+ File.write(tgt_override.to_s, stream.to_yaml)
420
+ externalized_suffixes = shared_keys
421
+ shared_keys.each { |k| Log.debug "shared #{k} -> #{source_project}_#{k}" }
422
+ if Log.debug?
423
+ Log.debug "added shared volumes to #{File.basename(tgt_override.to_s)}"
424
+ else
425
+ Log.info "updated #{File.basename(tgt_override.to_s)}"
426
+ end
427
+ else
428
+ Log.warn_ "no source volumes found for share_volumes; worktree will use its own isolated copies"
429
+ end
430
+ else
431
+ Log.warn_ "#{tgt_override}: 'volumes' is not a mapping; skipping shared volume override"
432
+ end
433
+ rescue Psych::SyntaxError => e
434
+ Log.warn_ "could not parse #{tgt_override}: #{e.message}; skipping shared volume override"
435
+ end
436
+ end
437
+ end
438
+
439
+ # Write identity env vars before compose up so variable interpolation in
440
+ # project-specific override files resolves correctly during volume creation.
441
+ tgt_env_path = target_path / env_filename
442
+ cpn_action = EnvFile.upsert(tgt_env_path.to_s, "COMPOSE_PROJECT_NAME", target_project)
443
+ host_action = EnvFile.upsert(tgt_env_path.to_s, config[:host_name], name)
444
+ pre_suffix = config[:host_name_suffix].to_s
445
+ suffix_action = EnvFile.upsert(tgt_env_path.to_s, "HOST_NAME_SUFFIX", pre_suffix) unless pre_suffix.empty?
446
+
447
+ override_file = config[:compose_override_file].to_s
448
+ if !override_file.empty? && (target_path / override_file).file?
449
+ current_cf = EnvFile.parse(tgt_env_path.to_s)["COMPOSE_FILE"].to_s
450
+ parts = current_cf.split(":").map(&:strip).reject(&:empty?)
451
+ unless parts.include?(override_file)
452
+ cf_action = EnvFile.upsert(tgt_env_path.to_s, "COMPOSE_FILE", (parts + [override_file]).join(":"))
453
+ Log.debug "appended #{override_file} to COMPOSE_FILE (#{cf_action}) in #{tgt_env_path}"
454
+ end
455
+ end
456
+
457
+ # Snapshot volumes that exist before --no-start for idempotency: if a
458
+ # target volume is already present, skip copying data into it.
459
+ all_vols_out, _, _ = Sh.capture3("docker", "volume", "ls", "--format", "{{.Name}}")
460
+ pre_existing_vols = all_vols_out.lines.map(&:strip).freeze
461
+
462
+ # Let Compose create all non-external volumes with its ownership labels
463
+ # so `docker compose up` later doesn't warn about externally managed volumes.
464
+ #
465
+ # Services are created one at a time rather than via a single `up` for
466
+ # every service: when two containers mounting the same brand-new empty
467
+ # volume are *created* concurrently, Docker races to populate that volume
468
+ # from the image's baked-in content for each of them, and the loser fails
469
+ # with "failed to mkdir ...: file exists" (e.g. multiple services sharing
470
+ # a gem-cache volume). Creating services sequentially means a volume is
471
+ # already non-empty by the time the second service mounts it, so Docker
472
+ # skips the populate step entirely and there's nothing left to race on.
473
+ services_out, services_err, services_st = Sh.capture3(
474
+ "docker", "compose",
475
+ "--project-directory", target_path.to_s,
476
+ "--project-name", target_project,
477
+ "config", "--services",
478
+ chdir: target_path.to_s
479
+ )
480
+ services = services_st.success? ? services_out.lines.map(&:strip).reject(&:empty?) : []
481
+
482
+ no_start_errors = []
483
+ Spinner.with_spinner("creating target volumes") do
484
+ if services.empty?
485
+ Log.warn_ "could not list compose services (#{services_err.strip}); falling back to single up --no-start"
486
+ _, err, st = Sh.capture3(
487
+ "docker", "compose",
488
+ "--project-directory", target_path.to_s,
489
+ "--project-name", target_project,
490
+ "up", "--no-start", "--no-build",
491
+ chdir: target_path.to_s
492
+ )
493
+ no_start_errors << err.strip unless st.success?
494
+ else
495
+ services.each do |service|
496
+ _, err, st = Sh.capture3(
497
+ "docker", "compose",
498
+ "--project-directory", target_path.to_s,
499
+ "--project-name", target_project,
500
+ "up", "--no-start", "--no-build", service,
501
+ chdir: target_path.to_s
502
+ )
503
+ no_start_errors << "#{service}: #{err.strip}" unless st.success?
504
+ end
505
+ end
506
+ end
507
+ Log.warn_ "docker compose up --no-start: #{no_start_errors.join("; ")}" unless no_start_errors.empty?
508
+
509
+ empty_count = 0
510
+ source_volumes.each do |src_vol|
511
+ suffix = src_vol.sub(prefix_re, "")
512
+ tgt_vol = "#{target_project}_#{suffix}"
513
+ next unless empty_volumes.include?(suffix)
514
+ Log.debug "created #{tgt_vol} (empty volume)"
515
+ empty_count += 1
516
+ end
517
+ Log.info "created #{empty_count} empty volumes" if empty_count > 0 && !Log.debug?
518
+
519
+ volume_copy_bytes = 0
520
+ source_volumes.each do |src_vol|
521
+ suffix = src_vol.sub(prefix_re, "")
522
+ tgt_vol = "#{target_project}_#{suffix}"
523
+
524
+ if share_volumes.include?(suffix)
525
+ if externalized_suffixes.include?(suffix)
526
+ volume_results << [src_vol, tgt_vol, :shared, "shared from source"]
527
+ else
528
+ Log.debug "skipped copy of #{src_vol} (not shared: no entry in #{override_rel}; worktree will create a fresh volume)"
529
+ volume_results << [src_vol, tgt_vol, :skipped, "not copied, not shared"]
530
+ end
531
+ next
532
+ end
533
+
534
+ if empty_volumes.include?(suffix)
535
+ volume_results << [src_vol, tgt_vol, :empty, "empty volume"]
536
+ next
537
+ end
538
+
539
+ if pre_existing_vols.include?(tgt_vol)
540
+ Log.warn_ "skipping #{tgt_vol} (already exists)"
541
+ volume_results << [src_vol, tgt_vol, :skipped, "already exists"]
542
+ next
543
+ end
544
+
545
+ # Explicit intent: live progress semantics unchanged with or without
546
+ # --log-file; the log file only receives the past-tense completion line.
547
+ st, err, vol_bytes = Volume.copy_with_progress(src_vol, tgt_vol)
548
+ if st.success?
549
+ volume_copy_bytes += vol_bytes.to_i
550
+ volume_results << [src_vol, tgt_vol, :copied, ""]
551
+ else
552
+ Log.warn_ "copy failed for #{tgt_vol}: #{err.strip}"
553
+ volume_results << [src_vol, tgt_vol, :failed, "copy failed: #{err.strip}"]
554
+ end
555
+ end
556
+
557
+ copied_count = volume_results.count { |_, _, st, _| st == :copied }
558
+ if copied_count > 0
559
+ Log.info "copied #{copied_count} volumes#{Sizes.note(volume_copy_bytes)}" unless Log.debug?
560
+ end
561
+
562
+ # === update .env in the new worktree ===
563
+ # The .env was carried over by the clone; only the fields that must
564
+ # differ per worktree need updating.
565
+
566
+ unless tgt_env_path.file?
567
+ Log.info "source has no #{env_filename}; creating one in target"
568
+ end
569
+
570
+ # COMPOSE_PROJECT_NAME is always auto-set to the target project name.
571
+ # config[:host_name] specifies which .env variable is auto-set to the
572
+ # worktree name (for domain routing). Keys in config[:skip_env_keys] are
573
+ # also auto-set from source. All others are presented interactively.
574
+ host_env_key = config[:host_name]
575
+ suffix = config[:host_name_suffix].to_s
576
+ host_domain_env_key = config[:host_domain_env_key].to_s
577
+ effective_skip_keys = config[:skip_env_keys].reject { |k| k == host_domain_env_key }
578
+ auto_set = (["COMPOSE_PROJECT_NAME", "COMPOSE_FILE", host_env_key, "HOST_NAME_SUFFIX"] +
579
+ (host_domain_env_key.empty? ? [] : [host_domain_env_key]) +
580
+ effective_skip_keys).freeze
581
+
582
+ Log.debug "#{env_action(cpn_action)} COMPOSE_PROJECT_NAME=#{target_project} in #{tgt_env_path}"
583
+ Log.debug "#{env_action(host_action)} #{host_env_key}=#{name} in #{tgt_env_path}"
584
+
585
+ unless suffix.empty?
586
+ Log.debug "#{env_action(suffix_action)} HOST_NAME_SUFFIX=#{suffix} in #{tgt_env_path}"
587
+ end
588
+
589
+ unless host_domain_env_key.empty? || suffix.empty?
590
+ domain_val = "#{name}.#{suffix}"
591
+ domain_action = EnvFile.upsert(tgt_env_path.to_s, host_domain_env_key, domain_val)
592
+ Log.debug "#{env_action(domain_action)} #{host_domain_env_key}=#{domain_val} in #{tgt_env_path}"
593
+ end
594
+
595
+ final_env_vars = { host_env_key => name }
596
+ final_env_vars["HOST_NAME_SUFFIX"] = suffix unless suffix.empty?
597
+ unless host_domain_env_key.empty? || suffix.empty?
598
+ final_env_vars[host_domain_env_key] = "#{name}.#{suffix}"
599
+ end
600
+ promptable = source_env_vars.reject { |k, v| auto_set.include?(k) || v.nil? || v.empty? }
601
+
602
+ # Collect sibling worktree .env values so the prompt can show what each
603
+ # worktree already has, helping the user pick non-conflicting values.
604
+ sibling_envs = {}
605
+ wt_out, _, wt_st = Sh.capture3("git", "-C", source_root.to_s, "worktree", "list", "--porcelain")
606
+ if wt_st.success?
607
+ source_real = source_root.realpath
608
+ target_real = begin; target_path.realpath; rescue Errno::ENOENT; target_path; end
609
+ wt_out.each_line do |line|
610
+ next unless line.start_with?("worktree ")
611
+ wt_path = Pathname.new(line.strip.delete_prefix("worktree "))
612
+ wt_real = begin; wt_path.realpath; rescue Errno::ENOENT; wt_path; end
613
+ next if wt_real == source_real || wt_real == target_real
614
+ env_path = wt_path / env_filename
615
+ sibling_envs[wt_path.basename.to_s] = EnvFile.parse(env_path.to_s) if env_path.file?
616
+ end
617
+ end
618
+
619
+ unless promptable.empty?
620
+ puts unless LogFile.enabled?
621
+ promptable.each do |key, value|
622
+ worktree_values = sibling_envs.filter_map { |wt, env| [wt, env[key]] if env[key] }.to_h
623
+ new_value = Prompt.for_env_var_change(key, value, worktree_values: worktree_values, force: force)
624
+ final_env_vars[key] = new_value
625
+
626
+ if new_value != value
627
+ action = EnvFile.upsert(tgt_env_path.to_s, key, new_value)
628
+ Log.debug "#{env_action(action)} #{key}=#{new_value} in #{tgt_env_path}"
629
+ else
630
+ Log.debug "unchanged #{key}=#{value} in #{tgt_env_path}"
631
+ end
632
+ end
633
+ end
634
+
635
+ Log.info "updated #{env_filename}" unless Log.debug?
636
+
637
+ # === final report ===
638
+
639
+ if Log.debug?
640
+ lines = []
641
+ lines << ""
642
+ lines << "=== create summary ==="
643
+ lines << "worktree: #{target_path}"
644
+ lines << "branch: #{branch}#{branch_exists ? " (existing, checked out)" : " (new)"}"
645
+ lines << "#{env_filename}: #{tgt_env_path}"
646
+ final_env_vars.each { |k, v| lines << " #{k}=#{v}" }
647
+ lines << "override: #{override_rel.empty? ? "(none configured)" : override_rel}"
648
+ lines << ""
649
+
650
+ if volume_results.any?
651
+ lines << "volumes:"
652
+ copied = volume_results.select { |_, _, st, _| st == :copied || st == :skipped || st == :failed }
653
+ empty = volume_results.select { |_, _, st, _| st == :empty }
654
+ shared = volume_results.select { |_, _, st, _| st == :shared }
655
+ (copied + empty + shared).each do |src, tgt, st, msg|
656
+ line = case st
657
+ when :shared then " [shared ] #{src} (#{msg})"
658
+ when :empty then " [empty ] #{tgt} (#{msg})"
659
+ else
660
+ l = " [#{st.to_s.ljust(7)}] #{src} -> #{tgt}"
661
+ msg.to_s.empty? ? l : "#{l} (#{msg})"
662
+ end
663
+ lines << line
664
+ end
665
+ lines << ""
666
+ end
667
+
668
+ # Debug-only console report: never write the block to the log file
669
+ # (per-line progress is already logged by the individual steps).
670
+ Log.section(lines.join("\n"), interactive: true, force: force)
671
+ end
672
+
673
+ any_failed = volume_results.any? { |_, _, st, _| st == :failed }
674
+
675
+ # === offer to rebase a stale existing branch onto default branch ===
676
+ # Worktree runtime state (gem/package volumes and their lockfiles) is
677
+ # replicated from the source's default branch. An existing branch that
678
+ # is behind the default branch can have tracked lockfiles that no
679
+ # longer match the copied state, which can prevent the stack from
680
+ # booting. Offer to rebase onto the default branch before the user
681
+ # moves into the worktree.
682
+ if branch_exists
683
+ behind_out, _, behind_st = Sh.capture3(
684
+ "git", "-C", target_path.to_s, "rev-list", "--count", "HEAD..#{default_branch}"
685
+ )
686
+ behind = behind_st.success? ? behind_out.strip.to_i : 0
687
+ if behind > 0
688
+ Log.warn_ "branch '#{branch}' is #{behind} commit(s) behind #{default_branch}"
689
+ Log.warn_ "its lockfiles may not match the replicated gem/package"
690
+ Log.warn_ "volumes, which can prevent the stack from booting"
691
+ if Prompt.confirm("rebase '#{branch}' onto #{default_branch} now? [Y/n]:", default: :yes, force: force)
692
+ _, _, rb_st = Spinner.with_spinner("rebasing #{branch} onto #{default_branch}") do
693
+ Sh.capture3("git", "-C", target_path.to_s, "rebase", default_branch)
694
+ end
695
+ if rb_st.success?
696
+ Log.info "rebased #{branch} onto #{default_branch}"
697
+ else
698
+ Sh.capture3("git", "-C", target_path.to_s, "rebase", "--abort")
699
+ Log.warn_ "rebase conflict — aborted; resolve manually with `ctree rebase` from the worktree"
700
+ end
701
+ else
702
+ Log.info "skipped rebase; run `ctree rebase` from the worktree later if needed"
703
+ end
704
+ end
705
+ end
706
+
707
+ exit(any_failed ? 2 : 0)
708
+ end
709
+
710
+ def env_action(action)
711
+ action == :replaced ? "updated" : "added"
712
+ end
713
+ end
714
+ end