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/create.rb
ADDED
|
@@ -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
|