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/cli.rb ADDED
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ctree
4
+ module CLI
5
+ module_function
6
+
7
+ COMMANDS = [
8
+ ["create", "<worktree_name> <branch_name>", "create a sibling worktree on a branch"],
9
+ ["delete", "<worktree_name>", "remove a worktree and its Docker resources"],
10
+ ["list", "[all | free | used]", "list worktrees for the current source repo"],
11
+ ["rebase", "", "rebase the worktree onto the source repo's master"],
12
+ ["update", "", "sync images, volumes, and files from source"],
13
+ ["free", "", "reset the worktree to a free placeholder branch"],
14
+ ["env", "[list | check | fix]", "manage the worktree's .env against the source"],
15
+ ["version", "", "print the installed ctree version"],
16
+ ["domain", "[list | [add | delete] <tld>]", "configure local DNS resolution for a TLD (macOS only)"],
17
+ ["config", "[list | add | delete]", "manage the per-repo .ctree/config.yml"],
18
+ ["compose-config", "[list | check | fix]", "manage shared-volume references in the compose override"],
19
+ ["sync", "", "sync the worktree by rebasing and updating"],
20
+ ["help", "[command]", "show detailed help for a command"]
21
+ ].freeze
22
+
23
+ COMMAND_SIGNATURES = COMMANDS.map { |name, args, _| args.empty? ? name : "#{name} #{args}" }.freeze
24
+
25
+ COMMAND_SIGNATURE_WIDTH = COMMAND_SIGNATURES.map(&:length).max + 2
26
+
27
+ COMMANDS_TABLE = COMMAND_SIGNATURES.zip(COMMANDS).map { |sig, (_, _, desc)|
28
+ " #{sig.ljust(COMMAND_SIGNATURE_WIDTH)}#{desc}"
29
+ }.join("\n").freeze
30
+
31
+ USAGE = <<~USAGE
32
+ Replicate a Docker Compose dev tree as a sibling git worktree with its own branch, volumes, and .env.
33
+
34
+ Usage:
35
+ ctree [command]
36
+
37
+ Available Commands:
38
+ #{COMMANDS_TABLE}
39
+
40
+ Most commands run from the source repository. `update`, `rebase`, `free`,
41
+ `env`, and `compose-config` run from inside a worktree. Use "ctree help
42
+ <command>" for more information about a command and its options.
43
+ USAGE
44
+
45
+ HELP = {
46
+ "create" => <<~HELP,
47
+ Usage:
48
+ ctree create <worktree_name> <branch_name>
49
+
50
+ Creates a sibling worktree at ../<worktree_name> on <branch_name>. The entire
51
+ source directory is cloned using copy-on-write (clonefile on macOS, cp --reflink
52
+ on Linux). Docker volumes are replicated under the new compose project name and
53
+ a tailored .env is written — prompting you to accept or change each value.
54
+
55
+ --config <path> replaces the repo's .ctree/config.yml with a custom config file
56
+ for this worktree. The custom file is merged over the shipped defaults and
57
+ persisted into the worktree's .ctree/config.yml so that later commands
58
+ (update, rebase, etc.) continue to use it.
59
+
60
+ --force skips all confirmation prompts (assuming the bracket default, or
61
+ the shown value for per-var .env prompts) so create runs non-interactively.
62
+
63
+ --log-file <path> writes full output to <path> instead of the console,
64
+ appending if it already exists (creating it otherwise). Implies
65
+ --force (prompts are skipped; defaults are assumed).
66
+ HELP
67
+ "delete" => <<~HELP,
68
+ Usage:
69
+ ctree delete <worktree_name>
70
+
71
+ Removes a worktree: lists everything to be deleted, requires explicit "yes"
72
+ confirmation, then tears down the directory, git worktree registration,
73
+ per-project Docker volumes, and any running compose stack. Branches are
74
+ always preserved.
75
+
76
+ --force skips the confirmation prompt and proceeds with deletion.
77
+
78
+ --log-file <path> writes full output to <path> instead of the console,
79
+ appending if it already exists (creating it otherwise). Implies
80
+ --force (prompts are skipped; defaults are assumed).
81
+ HELP
82
+ "list" => <<~HELP,
83
+ Usage:
84
+ ctree list [all | free | used]
85
+
86
+ Lists all worktrees for the current source repo. "free" shows only worktrees
87
+ on a placeholder branch (prefix configured via free_branch_prefix in
88
+ .ctree/config.yml, default "FREE-"); "used" shows only worktrees on a
89
+ non-free branch. Omitting the filter (or passing "all") lists everything.
90
+
91
+ --no-current suppresses the 'current' tag in the list output
92
+ HELP
93
+ "update" => <<~HELP,
94
+ Usage:
95
+ ctree update
96
+
97
+ Run from inside a worktree. Re-tags source images to the worktree's project
98
+ name, rsyncs allowlisted volumes (update_volumes), and copies paths listed
99
+ in the update config key. Warns and prompts before running if the source repo
100
+ is on a non-default branch.
101
+
102
+ --force assumes the shown defaults for the prompts: the branch-mismatch
103
+ prompt defaults to *not* proceeding — so --force with a non-default source
104
+ branch will abort the update rather than forcing it through.
105
+
106
+ --log-file <path> writes full output to <path> instead of the console,
107
+ appending if it already exists (creating it otherwise). Implies
108
+ --force (prompts are skipped; defaults are assumed).
109
+ HELP
110
+ "rebase" => <<~HELP,
111
+ Usage:
112
+ ctree rebase
113
+
114
+ Run from inside a worktree. Does two things in order:
115
+ 1. Rebases the worktree's current branch onto the source repo's master.
116
+ Skips if master is already an ancestor. Aborts if there are conflicts.
117
+ 2. Rebases each embedded git repo (directories listed under `rebase` in
118
+ .ctree/config.yml, e.g. gems/plugins) from its source counterpart.
119
+
120
+ All operations are local — no network calls. Update the source repo first,
121
+ then run `ctree rebase` from the worktree to catch it up.
122
+
123
+ --force assumes the shown default for the uncommitted-changes prompt,
124
+ which defaults to *not* proceeding — so --force will abort a rebase run
125
+ against a dirty worktree rather than forcing it through.
126
+
127
+ --log-file <path> writes full output to <path> instead of the console,
128
+ appending if it already exists (creating it otherwise). Implies
129
+ --force (prompts are skipped; defaults are assumed).
130
+ HELP
131
+ "free" => <<~HELP,
132
+ Usage:
133
+ ctree free
134
+
135
+ Run from inside a worktree. Resets the worktree to a free placeholder branch
136
+ so it can be reused. Checks out the first available branch not currently
137
+ checked out in any worktree whose name starts with free_branch_prefix (default
138
+ "FREE-"). If all are occupied, creates the next sequential one, filling gaps
139
+ (FREE-001 + FREE-003 occupied → creates FREE-002). The prefix is configurable
140
+ via free_branch_prefix in .ctree/config.yml.
141
+
142
+ HELP
143
+ "env" => <<~HELP,
144
+ Usage:
145
+ ctree env [list | check | fix]
146
+
147
+ Run from inside a worktree. Manages the worktree's .env relative to the
148
+ source repo's .env.
149
+
150
+ "list" Prints all key=value pairs from the worktree .env.
151
+
152
+ "check" Reports discrepancies between source and worktree .env:
153
+ [missing] KEY — in source but absent from worktree
154
+ [extra] KEY — in worktree but absent from source
155
+ Exits 1 if any issues found. ctree-managed keys
156
+ (COMPOSE_PROJECT_NAME, HOST_NAME, etc.) and keys in
157
+ skip_env_keys (.ctree/config.yml) are never reported as extra.
158
+
159
+ "fix" Resolves discrepancies interactively:
160
+ Missing keys — prompted with source value as default
161
+ Extra keys — offered for deletion (y/N)
162
+ Keys already present in both are left untouched. ctree-managed
163
+ keys and skip_env_keys are never offered for deletion.
164
+
165
+ --force keeps source values for missing keys (no prompt) and
166
+ assumes the deletion default ("no") for extra keys.
167
+ HELP
168
+ "compose-config" => <<~HELP,
169
+ Usage:
170
+ ctree compose-config [list | check | fix]
171
+
172
+ Run from inside a worktree. Manages shared-volume external references in the
173
+ compose override file. "list" shows current state; "check" reports missing or
174
+ incorrect entries; "fix" writes the corrections automatically.
175
+ HELP
176
+ "config" => <<~HELP,
177
+ Usage:
178
+ ctree config [list | add | delete]
179
+
180
+ Manages the per-repo .ctree/config.yml. "list" prints the resolved config
181
+ (defaults merged with repo overrides); "add" scaffolds a new config file with
182
+ annotated defaults; "delete" removes the file and its directory.
183
+
184
+ --force skips confirmation: "add" resets an existing config file (default
185
+ no, so it won't under --force) and "delete" proceeds with removal (default
186
+ yes for explicit-typing prompts).
187
+ HELP
188
+ "domain" => <<~HELP,
189
+ Usage:
190
+ ctree domain [list | [add | delete] <tld>]
191
+
192
+ macOS only. Configures local DNS resolution for a TLD. "add <tld>" adds a
193
+ wildcard dnsmasq rule resolving *.tld to 127.0.0.1 and writes a macOS
194
+ resolver file at /etc/resolver/<tld> (requires sudo). "delete <tld>" removes
195
+ both. "list" shows TLDs currently configured in dnsmasq and whether their
196
+ resolver files are present.
197
+ HELP
198
+ "version" => <<~HELP,
199
+ Usage:
200
+ ctree version
201
+
202
+ Prints the installed ctree version.
203
+ HELP
204
+ "sync" => <<~HELP,
205
+ Usage:
206
+ ctree sync
207
+
208
+ Run from inside a worktree. Equivalent to running rebase and update
209
+ commands. All operations are local — no network calls. Update the source
210
+ repo first, then run `ctree sync` from the worktree to catch it up.
211
+
212
+ --force skips confirmations and assumes the default values.
213
+
214
+ --log-file <path> writes full output to <path> instead of the console,
215
+ appending if it already exists (creating it otherwise). Implies
216
+ --force (prompts are skipped; defaults are assumed).
217
+ HELP
218
+ }.freeze
219
+
220
+ def usage_and_exit
221
+ puts USAGE
222
+ exit 1
223
+ end
224
+
225
+ # Worktree names double as Docker Compose project names, which Docker
226
+ # requires to be lowercase letters/digits/'-'/'_'. If the user passed
227
+ # something close, suggest the sanitized form.
228
+ def invalid_name_message(name)
229
+ base = "invalid name '#{name}'; must be lowercase letters, digits, '-', or '_', " \
230
+ "starting with a letter or digit (Docker Compose project name constraint)"
231
+ suggestion = Naming.sanitize_compose_project_name(name)
232
+ return base if suggestion.empty? || suggestion == name || suggestion !~ NAME_PATTERN
233
+ "#{base} — try '#{suggestion}'"
234
+ end
235
+
236
+ LOG_FILE_COMMANDS = %w[create delete rebase update sync].freeze
237
+
238
+ # Wraps create/delete/rebase/update invocation when --log-file is given:
239
+ # redirects output to the file and shows status on the console. On failure,
240
+ # stays silent (matches no-log-file behavior). A no-op if log_file is nil.
241
+ def with_logging(log_file, progress_message, done_message)
242
+ return yield unless log_file
243
+
244
+ LogFile.configure(log_file)
245
+ Scroller.start(progress_message)
246
+ begin
247
+ yield
248
+ ensure
249
+ status = $!.is_a?(SystemExit) ? $!.status : ($! ? 1 : 0)
250
+ Scroller.stop(status.zero? ? done_message : nil)
251
+ end
252
+ end
253
+
254
+ def run(argv)
255
+ usage_and_exit if argv.empty?
256
+
257
+ force = argv.include?("--force")
258
+ argv = argv.reject { |a| a == "--force" }
259
+
260
+ log_file = nil
261
+ argv = argv.reject do |a|
262
+ if a.start_with?("--log-file=")
263
+ log_file = a.sub("--log-file=", "")
264
+ true
265
+ elsif a == "--log-file" && argv.index(a) + 1 < argv.length && !argv[argv.index(a) + 1].start_with?("-")
266
+ # Extract the next element as the path and remove both --log-file and the path from argv
267
+ log_file = argv[argv.index(a) + 1]
268
+ argv.delete_at(argv.index(a) + 1)
269
+ true
270
+ end
271
+ end
272
+
273
+ # --log-file implies non-interactive: with no console visible while logging
274
+ # there's nowhere to show a live prompt, so it always runs like --force.
275
+ force = true if log_file
276
+
277
+ verb = argv[0]
278
+
279
+ if log_file
280
+ Log.die "--log-file requires a path, e.g. --log-file /tmp/ctree.log" if log_file.empty?
281
+ unless LOG_FILE_COMMANDS.include?(verb)
282
+ Log.die "--log-file is only supported for #{LOG_FILE_COMMANDS.join(", ")}"
283
+ end
284
+ parent = File.dirname(File.expand_path(log_file))
285
+ unless File.directory?(parent) && File.writable?(parent)
286
+ Log.die "--log-file parent directory does not exist or is not writable: #{parent}"
287
+ end
288
+ end
289
+
290
+ case verb
291
+ when "version"
292
+ puts "ctree #{VERSION}"
293
+ when "create"
294
+ if argv.length == 3
295
+ config_path = nil
296
+ elsif (idx = argv.index { |a| a.start_with?("--config=") })
297
+ config_path = argv[idx].sub("--config=", "")
298
+ argv.delete_at(idx) # Clean up argv
299
+ elsif argv.length == 5 && argv[3] == "--config"
300
+ config_path = argv[4]
301
+ else
302
+ usage_and_exit
303
+ end
304
+ name = argv[1]
305
+ branch_arg = argv[2]
306
+ Log.die invalid_name_message(name) unless name =~ NAME_PATTERN
307
+ if branch_arg !~ BRANCH_NAME_PATTERN
308
+ Log.die "invalid branch name '#{branch_arg}'; must match #{BRANCH_NAME_PATTERN.inspect}"
309
+ end
310
+ with_logging(log_file, "creating worktree #{name}", "created worktree #{name}") do
311
+ Create.run(name: name, branch: branch_arg, config_path: config_path, force: force)
312
+ end
313
+ when "delete"
314
+ usage_and_exit if argv.length != 2
315
+ name = argv[1]
316
+ Log.die invalid_name_message(name) unless name =~ NAME_PATTERN
317
+ with_logging(log_file, "deleting worktree #{name}", "deleted worktree #{name}") do
318
+ Delete.run(name: name, force: force)
319
+ end
320
+ when "update"
321
+ usage_and_exit unless argv.length == 1
322
+ with_logging(log_file, "updating worktree", "updated worktree") do
323
+ Update.run(force: force)
324
+ end
325
+ when "free"
326
+ usage_and_exit unless argv.length == 1
327
+ Free.run
328
+ when "env"
329
+ subcommand = argv[1]
330
+ usage_and_exit unless %w[list check fix].include?(subcommand)
331
+ usage_and_exit unless argv.length == 2
332
+ EnvCmd.run(subcommand, force: force)
333
+ when "compose-config"
334
+ subcommand = argv[1]
335
+ usage_and_exit unless %w[list check fix].include?(subcommand)
336
+ usage_and_exit unless argv.length == 2
337
+ ComposeConfigCmd.run(subcommand)
338
+ when "rebase"
339
+ usage_and_exit unless argv.length == 1
340
+ with_logging(log_file, "rebasing worktree", "rebased worktree") do
341
+ Rebase.run(force: force)
342
+ end
343
+ when "sync"
344
+ usage_and_exit unless argv.length == 1
345
+ with_logging(log_file, "syncing worktree", "synced worktree") do
346
+ Sync.run(force: force)
347
+ end
348
+ when "list"
349
+ show_current = !argv.include?("--no-current")
350
+ argv.delete("--no-current") # Remove flag from argv to clean up parsing
351
+ case argv[1..]
352
+ when [], ["all"]
353
+ List.run(show_current: show_current)
354
+ when ["free"]
355
+ List.run(filter: :free, show_current: show_current)
356
+ when ["used"]
357
+ List.run(filter: :used, show_current: show_current)
358
+ else
359
+ usage_and_exit
360
+ end
361
+ when "domain"
362
+ subcommand = argv[1]
363
+ case subcommand
364
+ when "add"
365
+ usage_and_exit unless argv.length == 3
366
+ Domain.add(tld: argv[2])
367
+ when "delete"
368
+ usage_and_exit unless argv.length == 3
369
+ Domain.remove(tld: argv[2])
370
+ when "list"
371
+ usage_and_exit unless argv.length == 2
372
+ Domain.list
373
+ else
374
+ usage_and_exit
375
+ end
376
+ when "config"
377
+ case argv[1..]
378
+ when []
379
+ usage_and_exit
380
+ when ["list"]
381
+ Config.print_resolved!
382
+ when ["add"]
383
+ Config.add_local!(force: force)
384
+ when ["delete"]
385
+ Config.remove_local!(force: force)
386
+ else
387
+ usage_and_exit
388
+ end
389
+ when "help"
390
+ if argv.length == 1
391
+ puts USAGE
392
+ elsif (text = HELP[argv[1]])
393
+ print "\n#{text}\n"
394
+ else
395
+ Log.die "unknown command '#{argv[1]}'; available: #{HELP.keys.sort.join(", ")}"
396
+ end
397
+ else
398
+ usage_and_exit
399
+ end
400
+ end
401
+ end
402
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ctree
4
+ module ComposeConfigCmd
5
+ module_function
6
+
7
+ def run(subcommand)
8
+ target_path, source_root = resolve_worktree!
9
+ config = Config.load(target_path)
10
+ Log.log_prefix = config[:log_prefix]
11
+ override_rel = config[:compose_override_file].to_s
12
+ share_volumes = config[:share_volumes]
13
+ env_filename = config[:env_filename]
14
+
15
+ case subcommand
16
+ when "list"
17
+ cmd_list(target_path, override_rel)
18
+ when "check"
19
+ source_project, source_volumes = resolve_source!(source_root, env_filename)
20
+ cmd_check(target_path, override_rel, share_volumes, source_project, source_volumes)
21
+ when "fix"
22
+ source_project, source_volumes = resolve_source!(source_root, env_filename)
23
+ cmd_fix(target_path, override_rel, share_volumes, source_project, source_volumes)
24
+ else
25
+ Log.die "unknown subcommand '#{subcommand}'; use: list, check, or fix"
26
+ end
27
+ end
28
+
29
+ def resolve_worktree!
30
+ target_path = Pathname.pwd
31
+ toplevel_out, _, st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--show-toplevel")
32
+ Log.die "not inside a git repository" unless st.success?
33
+ git_top = Pathname.new(toplevel_out.strip)
34
+ unless target_path.realpath == git_top.realpath
35
+ Log.die "must be invoked from the worktree top-level"
36
+ end
37
+ common_out, _, c_st = Sh.capture3("git", "-C", target_path.to_s, "rev-parse", "--git-common-dir")
38
+ Log.die "could not resolve git common dir" unless c_st.success?
39
+ common = Pathname.new(common_out.strip)
40
+ common = (target_path / common) unless common.absolute?
41
+ source_root = common.parent.realpath
42
+ if source_root == target_path.realpath
43
+ Log.die "already in the source project; run ctree compose-config from inside a worktree"
44
+ end
45
+ [target_path.realpath, source_root]
46
+ end
47
+
48
+ def resolve_source!(source_root, env_filename)
49
+ source_env = EnvFile.parse(source_root / env_filename)
50
+ env_cpn = source_env["COMPOSE_PROJECT_NAME"]
51
+ source_project = (env_cpn && !env_cpn.empty?) ? env_cpn :
52
+ Naming.sanitize_compose_project_name(source_root.basename.to_s)
53
+
54
+ docker_ok = Sh.system("docker", "info", out: File::NULL, err: File::NULL)
55
+ Log.die "docker not reachable" unless docker_ok
56
+
57
+ vols_out, _, st = Sh.capture3("docker", "volume", "ls", "--format", "{{.Name}}")
58
+ Log.die "docker volume ls failed" unless st.success?
59
+ prefix_re = /\A#{Regexp.escape(source_project)}_/
60
+ source_volumes = vols_out.lines.map(&:strip).reject(&:empty?).select { |v| v =~ prefix_re }
61
+ [source_project, source_volumes]
62
+ end
63
+
64
+ def cmd_list(target_path, override_rel)
65
+ Log.die "compose_override_file not configured" if override_rel.empty?
66
+ override_path = target_path / override_rel
67
+ Log.die "#{override_rel} not found" unless override_path.file?
68
+ puts override_path.read
69
+ exit 0
70
+ end
71
+
72
+ def cmd_check(target_path, override_rel, share_volumes, source_project, source_volumes)
73
+ Log.die "compose_override_file not configured" if override_rel.empty?
74
+
75
+ if share_volumes.empty?
76
+ Log.info "no share_volumes configured"
77
+ exit 0
78
+ end
79
+
80
+ override_path = target_path / override_rel
81
+ unless override_path.file?
82
+ Log.warn_ "#{override_rel} not found in worktree"
83
+ exit 1
84
+ end
85
+
86
+ result = ComposeOverride.audit(
87
+ target_path: target_path, override_rel: override_rel,
88
+ share_volumes: share_volumes, source_project: source_project,
89
+ source_volumes: source_volumes
90
+ )
91
+
92
+ total = result[:fixable].size + result[:unfixable].size
93
+ if total > 0
94
+ if result[:fixable].any?
95
+ Log.warn_ "#{total} issue#{total == 1 ? "" : "s"} found — run `ctree compose-config fix` to fix"
96
+ else
97
+ Log.warn_ "#{total} issue#{total == 1 ? "" : "s"} found — source volumes missing, repair not possible"
98
+ end
99
+ exit 1
100
+ end
101
+
102
+ Log.info "#{override_rel} is valid"
103
+ exit 0
104
+ end
105
+
106
+ def cmd_fix(target_path, override_rel, share_volumes, source_project, source_volumes)
107
+ Log.die "compose_override_file not configured" if override_rel.empty?
108
+
109
+ if share_volumes.empty?
110
+ Log.info "no share_volumes configured"
111
+ exit 0
112
+ end
113
+
114
+ result = ComposeOverride.audit(
115
+ target_path: target_path, override_rel: override_rel,
116
+ share_volumes: share_volumes, source_project: source_project,
117
+ source_volumes: source_volumes
118
+ )
119
+
120
+ if result[:fixable].empty? && result[:unfixable].empty?
121
+ Log.info "#{override_rel} is already correct"
122
+ exit 0
123
+ end
124
+
125
+ ComposeOverride.fix(
126
+ target_path: target_path, override_rel: override_rel,
127
+ share_volumes: share_volumes, source_project: source_project,
128
+ source_volumes: source_volumes
129
+ )
130
+ exit 0
131
+ end
132
+
133
+ private_class_method :resolve_worktree!, :resolve_source!,
134
+ :cmd_list, :cmd_check, :cmd_fix
135
+ end
136
+ end