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/config.rb
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ctree
|
|
4
|
+
# Configuration is layered (lowest → highest priority):
|
|
5
|
+
# 1. lib/ctree/config.yml — shipped defaults (always the base).
|
|
6
|
+
# 2. <source_root>/.ctree/config.yml — per-repo override; created by
|
|
7
|
+
# `ctree config add`. Keys here win.
|
|
8
|
+
#
|
|
9
|
+
# Note on .ctree/config.yml: this file is intentionally NOT raw-cloned into
|
|
10
|
+
# worktrees (`.ctree` is hardcoded in the top-level exclusion list at the
|
|
11
|
+
# same tier as `.git`). Instead, `ctree create` explicitly recreates it in
|
|
12
|
+
# the new worktree from the source copy. This prevents stray files in source's
|
|
13
|
+
# `.ctree/` from propagating to worktrees. See CREATE.md for details.
|
|
14
|
+
#
|
|
15
|
+
# Schema (all keys optional):
|
|
16
|
+
# update_volumes: [<string>, ...]
|
|
17
|
+
# share_volumes: [<string>, ...]
|
|
18
|
+
# skip_env_keys: [<string>, ...]
|
|
19
|
+
module Config
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
SHIPPED_CONFIG_PATH = File.expand_path("config.yml", __dir__)
|
|
23
|
+
REPO_CONFIG_REL = File.join(".ctree", "config.yml")
|
|
24
|
+
ARRAY_KEYS = %w[update_volumes share_volumes skip_env_keys].freeze
|
|
25
|
+
OPTIONAL_ARRAY_KEYS = %w[rebase exclude update empty_volumes post_update_hooks post_rebase_hooks].freeze
|
|
26
|
+
STRING_KEYS = %w[env_filename host_name].freeze
|
|
27
|
+
OPTIONAL_STRING_KEYS = %w[host_name_suffix compose_override_file host_domain_env_key free_branch_prefix].freeze
|
|
28
|
+
BOOLEAN_KEYS = %w[log_prefix].freeze
|
|
29
|
+
VALID_LOG_LEVELS = %w[info debug].freeze
|
|
30
|
+
SUPPORTED_KEYS = (ARRAY_KEYS + OPTIONAL_ARRAY_KEYS + STRING_KEYS + OPTIONAL_STRING_KEYS + BOOLEAN_KEYS + %w[log_level]).freeze
|
|
31
|
+
|
|
32
|
+
def load(source_root)
|
|
33
|
+
# Layered lowest → highest priority. Shipped defaults are always the
|
|
34
|
+
# base so a partial repo config only overrides the keys it sets;
|
|
35
|
+
# unspecified keys fall through to the shipped values.
|
|
36
|
+
# 1. shipped defaults
|
|
37
|
+
# 2. per-repo (<source_root>/.ctree/config.yml)
|
|
38
|
+
result = load_yaml(SHIPPED_CONFIG_PATH, must_exist: true)
|
|
39
|
+
|
|
40
|
+
repo_path = File.join(source_root.to_s, REPO_CONFIG_REL)
|
|
41
|
+
if File.file?(repo_path)
|
|
42
|
+
result = result.merge(load_yaml(repo_path, must_exist: false)) { |_, _, v| v }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
validate_and_normalize(result, repo_path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Loads shipped defaults merged with a custom config file, skipping the
|
|
49
|
+
# repo's .ctree/config.yml layer entirely. The named file replaces what
|
|
50
|
+
# the repo config would have provided — shipped defaults are still the
|
|
51
|
+
# base underneath it.
|
|
52
|
+
#
|
|
53
|
+
# override_path - path to the custom config file, relative to Dir.pwd or
|
|
54
|
+
# absolute. Must exist, be parseable YAML, and be a top-
|
|
55
|
+
# level mapping. Dies with a clear message otherwise.
|
|
56
|
+
def load_with_override(override_path)
|
|
57
|
+
abs_path = File.expand_path(override_path, Dir.pwd)
|
|
58
|
+
result = load_yaml(SHIPPED_CONFIG_PATH, must_exist: true)
|
|
59
|
+
custom = load_yaml(abs_path, must_exist: true)
|
|
60
|
+
result = result.merge(custom) { |_, _, v| v }
|
|
61
|
+
validate_and_normalize(result, abs_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the shipped defaults as a normalized, symbol-keyed hash.
|
|
65
|
+
def defaults
|
|
66
|
+
raw = load_yaml(SHIPPED_CONFIG_PATH, must_exist: true)
|
|
67
|
+
validate_and_normalize(raw, SHIPPED_CONFIG_PATH)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Scaffolds <repo_top>/.ctree/config.yml from the shipped template so the
|
|
71
|
+
# repo has an annotated, committable per-repo config. Must be run inside a
|
|
72
|
+
# git repository; refuses to clobber an existing file without confirmation.
|
|
73
|
+
def add_local!(force: false)
|
|
74
|
+
top = repo_top_or_die
|
|
75
|
+
dir = top / ".ctree"
|
|
76
|
+
target = dir / "config.yml"
|
|
77
|
+
if target.exist?
|
|
78
|
+
unless Prompt.confirm("Config file exists: #{target}. Reset it? [y/N]:", default: :no, force: force)
|
|
79
|
+
Log.info "keeping existing config"
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
already_existed = target.exist?
|
|
85
|
+
FileUtils.mkdir_p(dir.to_s)
|
|
86
|
+
FileUtils.cp(SHIPPED_CONFIG_PATH, target.to_s)
|
|
87
|
+
Log.info "#{already_existed ? "reset" : "created"} #{target}"
|
|
88
|
+
Log.info "edit it to set this repo's ctree config"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Removes <repo_top>/.ctree/config.yml and the .ctree directory. Must be
|
|
92
|
+
# run inside a git repository. If .ctree holds files other than config.yml,
|
|
93
|
+
# the directory is kept (we don't delete content ctree didn't create).
|
|
94
|
+
def remove_local!(force: false)
|
|
95
|
+
top = repo_top_or_die
|
|
96
|
+
dir = top / ".ctree"
|
|
97
|
+
target = dir / "config.yml"
|
|
98
|
+
unless target.exist?
|
|
99
|
+
Log.info "no local config file found at #{target}"
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
puts
|
|
103
|
+
puts "[#{PROG}] About to delete:"
|
|
104
|
+
puts " #{target}"
|
|
105
|
+
puts " #{dir}/ (if empty after removing the config)"
|
|
106
|
+
puts
|
|
107
|
+
unless Prompt.confirm("Type 'yes' to confirm removal:", default: nil, force: force)
|
|
108
|
+
Log.info "aborted — config file kept"
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
File.delete(target.to_s)
|
|
112
|
+
Log.info "deleted #{target}"
|
|
113
|
+
|
|
114
|
+
remaining = dir.children
|
|
115
|
+
if remaining.empty?
|
|
116
|
+
Dir.rmdir(dir.to_s)
|
|
117
|
+
Log.info "removed #{dir}"
|
|
118
|
+
else
|
|
119
|
+
Log.warn_ "kept #{dir} — it still contains: #{remaining.map(&:basename).join(", ")}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Prints the repo's local config as YAML, or notes that none exists.
|
|
124
|
+
def print_resolved!
|
|
125
|
+
source_root = ensure_at_repo_top!
|
|
126
|
+
repo_path = File.join(source_root.to_s, REPO_CONFIG_REL)
|
|
127
|
+
unless File.file?(repo_path)
|
|
128
|
+
Log.info "no local config file (#{REPO_CONFIG_REL}); run `#{PROG} config add` to create one"
|
|
129
|
+
return
|
|
130
|
+
end
|
|
131
|
+
puts "# Local config: #{repo_path}"
|
|
132
|
+
resolved = load(source_root)
|
|
133
|
+
puts YAML.dump(resolved.transform_keys(&:to_s))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ensure_at_repo_top!
|
|
137
|
+
source_root = Pathname.pwd
|
|
138
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", source_root.to_s, "rev-parse", "--show-toplevel")
|
|
139
|
+
Log.die "not inside a git repository" unless status.success?
|
|
140
|
+
git_top = Pathname.new(toplevel_out.strip)
|
|
141
|
+
unless source_root.realpath == git_top.realpath
|
|
142
|
+
Log.die "must be invoked from project top-level (cwd=#{source_root}, top=#{git_top})"
|
|
143
|
+
end
|
|
144
|
+
source_root
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Returns the repository top-level for the current directory, or dies with
|
|
148
|
+
# a warning. Unlike ensure_at_repo_top!, this works from any subdirectory
|
|
149
|
+
# of the repo — config add/remove always operate on the repo top-level.
|
|
150
|
+
def repo_top_or_die
|
|
151
|
+
toplevel_out, _, status = Sh.capture3("git", "-C", Pathname.pwd.to_s, "rev-parse", "--show-toplevel")
|
|
152
|
+
Log.die "ctree config must be run from within a git repository" unless status.success?
|
|
153
|
+
Pathname.new(toplevel_out.strip)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def load_yaml(path, must_exist:)
|
|
157
|
+
raw = nil
|
|
158
|
+
begin
|
|
159
|
+
raw = YAML.safe_load(File.read(path), aliases: true)
|
|
160
|
+
rescue Errno::ENOENT
|
|
161
|
+
Log.die "ctree defaults file missing: #{path}" if must_exist
|
|
162
|
+
return {}
|
|
163
|
+
rescue Psych::SyntaxError => e
|
|
164
|
+
if must_exist
|
|
165
|
+
Log.die "could not parse ctree defaults #{path}: #{e.message}"
|
|
166
|
+
else
|
|
167
|
+
Log.warn_ "could not parse #{path}: #{e.message}; ignoring overrides"
|
|
168
|
+
return {}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
unless raw.is_a?(Hash)
|
|
173
|
+
Log.die "#{path}: top-level must be a YAML mapping" if must_exist
|
|
174
|
+
Log.warn_ "#{path}: top-level must be a YAML mapping; ignoring overrides"
|
|
175
|
+
return {}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
raw
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate_and_normalize(merged, config_path)
|
|
182
|
+
result = {}
|
|
183
|
+
# Insertion order here is the order `ctree config` prints. Keep
|
|
184
|
+
# host_name and host_name_suffix adjacent.
|
|
185
|
+
ARRAY_KEYS.each do |key|
|
|
186
|
+
val = merged[key]
|
|
187
|
+
unless val.is_a?(Array) && val.all? { |x| x.is_a?(String) }
|
|
188
|
+
Log.die "missing or invalid #{key.inspect} in ctree config (check config.yml or #{config_path})"
|
|
189
|
+
end
|
|
190
|
+
result[key.to_sym] = val.dup
|
|
191
|
+
end
|
|
192
|
+
OPTIONAL_ARRAY_KEYS.each do |key|
|
|
193
|
+
val = merged[key]
|
|
194
|
+
unless val.nil? || (val.is_a?(Array) && val.all? { |x| x.is_a?(String) })
|
|
195
|
+
Log.die "missing or invalid #{key.inspect} in ctree config (check config.yml or #{config_path})"
|
|
196
|
+
end
|
|
197
|
+
result[key.to_sym] = (val || []).dup
|
|
198
|
+
end
|
|
199
|
+
STRING_KEYS.each do |key|
|
|
200
|
+
val = merged[key]
|
|
201
|
+
unless val.is_a?(String) && !val.strip.empty?
|
|
202
|
+
Log.die "missing or invalid #{key.inspect} in ctree config (check config.yml or #{config_path})"
|
|
203
|
+
end
|
|
204
|
+
result[key.to_sym] = val.dup
|
|
205
|
+
end
|
|
206
|
+
OPTIONAL_STRING_KEYS.each do |key|
|
|
207
|
+
val = merged[key]
|
|
208
|
+
unless val.nil? || val.is_a?(String)
|
|
209
|
+
Log.die "missing or invalid #{key.inspect} in ctree config (check config.yml or #{config_path})"
|
|
210
|
+
end
|
|
211
|
+
result[key.to_sym] = (val || "").dup
|
|
212
|
+
end
|
|
213
|
+
BOOLEAN_KEYS.each do |key|
|
|
214
|
+
val = merged[key]
|
|
215
|
+
unless val.nil? || val.is_a?(TrueClass) || val.is_a?(FalseClass)
|
|
216
|
+
Log.die "missing or invalid #{key.inspect} in ctree config (check config.yml or #{config_path})"
|
|
217
|
+
end
|
|
218
|
+
result[key.to_sym] = (val.nil? ? true : val) # default to true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# NEW: Apply environment variable override for log_prefix
|
|
222
|
+
if ENV['CTREE_LOG_PREFIX']
|
|
223
|
+
env_val = ENV['CTREE_LOG_PREFIX'].downcase
|
|
224
|
+
if %w[true false].include?(env_val)
|
|
225
|
+
result[:log_prefix] = (env_val == 'true')
|
|
226
|
+
else
|
|
227
|
+
Log.warn_ "invalid value for CTREE_LOG_PREFIX: #{ENV['CTREE_LOG_PREFIX']}. Expected 'true' or 'false'. Ignoring."
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
log_level_val = merged["log_level"]
|
|
232
|
+
if log_level_val.nil?
|
|
233
|
+
result[:log_level] = "info"
|
|
234
|
+
elsif VALID_LOG_LEVELS.include?(log_level_val)
|
|
235
|
+
result[:log_level] = log_level_val
|
|
236
|
+
else
|
|
237
|
+
Log.die "invalid log_level #{log_level_val.inspect} in ctree config; must be one of: #{VALID_LOG_LEVELS.join(", ")}"
|
|
238
|
+
end
|
|
239
|
+
result
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Default ctree configuration shipped with the package.
|
|
2
|
+
#
|
|
3
|
+
# Override per-repo by creating <source_root>/.ctree/config.yml with the same
|
|
4
|
+
# schema. Keys present in the user file replace the defaults below; absent
|
|
5
|
+
# keys fall back to these values.
|
|
6
|
+
|
|
7
|
+
# Name of the env file ctree reads and writes in both the source repo and
|
|
8
|
+
# each worktree (e.g. COMPOSE_PROJECT_NAME, HOST_NAME, and any prompted
|
|
9
|
+
# variables). Change this if a project uses a non-standard env file name.
|
|
10
|
+
env_filename: .env
|
|
11
|
+
|
|
12
|
+
# The suffix part of Docker volume names to share between source and worktree.
|
|
13
|
+
# Suffix = everything after the leading `<project>_` in the full volume name.
|
|
14
|
+
# Example: full name `myapp_node_modules` → suffix is `node_modules`.
|
|
15
|
+
# ctree rewrites the worktree's compose_override_file to mount the source
|
|
16
|
+
# project's volume directly (external: true) rather than creating a new one.
|
|
17
|
+
share_volumes: []
|
|
18
|
+
|
|
19
|
+
# The suffix part of Docker volume names that `ctree update` is allowed to
|
|
20
|
+
# rsync from source into the worktree. Suffix = everything after `<project>_`.
|
|
21
|
+
# Example: full name `myapp_bundler` → suffix is `bundler`.
|
|
22
|
+
# This is an allowlist — volumes not listed are left untouched so new
|
|
23
|
+
# stateful volumes fail closed rather than being silently overwritten.
|
|
24
|
+
update_volumes: []
|
|
25
|
+
|
|
26
|
+
# Volume name suffixes that ctree creates empty in the worktree rather than
|
|
27
|
+
# copying from source. Use for transient volumes (e.g. logs, tmp) whose
|
|
28
|
+
# content is not meaningful to a fresh branch environment.
|
|
29
|
+
# Suffix = everything after the leading `<project>_`.
|
|
30
|
+
# During ctree create: volume is created with no data (rsync skipped).
|
|
31
|
+
# During ctree update: volume is skipped regardless of update_volumes.
|
|
32
|
+
empty_volumes: []
|
|
33
|
+
|
|
34
|
+
# Variables in the source .env that are auto-set during `ctree create` and
|
|
35
|
+
# NOT presented as interactive prompts. COMPOSE_PROJECT_NAME is always
|
|
36
|
+
# excluded (it is set to the target project name) regardless of this list.
|
|
37
|
+
skip_env_keys: []
|
|
38
|
+
|
|
39
|
+
# The .env variable that ctree sets to the worktree name so the worktree
|
|
40
|
+
# can be accessed via its own domain (e.g. worktree-abc.docker). ctree sets
|
|
41
|
+
# this automatically — the user is never prompted for it.
|
|
42
|
+
host_name: HOST_NAME
|
|
43
|
+
|
|
44
|
+
# The TLD written to HOST_NAME_SUFFIX in the worktree .env. When non-empty,
|
|
45
|
+
# ctree writes HOST_NAME_SUFFIX=<value> so compose files can construct
|
|
46
|
+
# ${HOST_NAME}.${HOST_NAME_SUFFIX} for domain routing. Leave empty for
|
|
47
|
+
# projects that don't use HOST_NAME_SUFFIX.
|
|
48
|
+
host_name_suffix: ""
|
|
49
|
+
|
|
50
|
+
# The .env variable that ctree sets to <HOST_NAME>.<HOST_NAME_SUFFIX> during
|
|
51
|
+
# `ctree create`. Use when a project reads its full worktree domain from a
|
|
52
|
+
# single .env variable (e.g. MYAPP_DOMAIN). ctree computes the value as
|
|
53
|
+
# <worktree_name>.<host_name_suffix> and writes it automatically — no prompt.
|
|
54
|
+
# If this key also appears in skip_env_keys it is ignored there; this setting
|
|
55
|
+
# takes precedence. Leave empty if the project does not use such a variable.
|
|
56
|
+
host_domain_env_key: ""
|
|
57
|
+
|
|
58
|
+
# Directories (relative to repo root) scanned one level deep for embedded
|
|
59
|
+
# git repos during `ctree rebase`. Each subdirectory that has a .git entry
|
|
60
|
+
# and is NOT listed in .gitmodules is treated as an independent embedded repo
|
|
61
|
+
# and rebased from its source counterpart (local-only, no network).
|
|
62
|
+
# Example: [gems/plugins] will rebase all embedded repos in the worktree's
|
|
63
|
+
# gems/plugins directory from <source_root>/gems/plugins/*.
|
|
64
|
+
rebase: []
|
|
65
|
+
|
|
66
|
+
# The docker-compose override file that ctree automatically rewrites during
|
|
67
|
+
# `ctree create` to mark shared volumes as external references to the source
|
|
68
|
+
# project's volumes.
|
|
69
|
+
compose_override_file: docker-compose.local.ctree.yml
|
|
70
|
+
|
|
71
|
+
# Top-level names (files or directories) to skip when cloning the source
|
|
72
|
+
# working tree into a new worktree during `ctree create`. Matched by
|
|
73
|
+
# basename — top-level items only.
|
|
74
|
+
# Note: .ctree is always implicitly excluded (it is a reserved directory
|
|
75
|
+
# managed by ctree itself). Listing it here is a harmless no-op.
|
|
76
|
+
# Examples: node_modules (directory), .env.local (file)
|
|
77
|
+
exclude: []
|
|
78
|
+
|
|
79
|
+
# Repo-relative files or directories copied from source to worktree during
|
|
80
|
+
# `ctree update` and `ctree create`. Use for gitignored artifacts that
|
|
81
|
+
# describe the state of shared Docker volumes (e.g. Gemfile.*.plugins.lock
|
|
82
|
+
# next to gem volumes). These are not updated by `git rebase` since they are
|
|
83
|
+
# gitignored, but must match the source after a volume sync for
|
|
84
|
+
# gems/packages to resolve. Files are copied directly; directories are
|
|
85
|
+
# synced recursively (additions, modifications, and deletions).
|
|
86
|
+
# Example: [Gemfile.rails8.plugins.lock]
|
|
87
|
+
update: []
|
|
88
|
+
|
|
89
|
+
# Shell commands run automatically after `ctree update` completes, regardless
|
|
90
|
+
# of whether any volume syncs failed. Commands execute in order from the
|
|
91
|
+
# worktree root; execution stops on the first non-zero exit.
|
|
92
|
+
# Example:
|
|
93
|
+
# post_update_hooks:
|
|
94
|
+
# - docker compose run --rm web bundle exec rake db:migrate RAILS_ENV=development
|
|
95
|
+
post_update_hooks: []
|
|
96
|
+
|
|
97
|
+
# Branch name prefix used by `ctree list` to detect free (placeholder) worktrees.
|
|
98
|
+
# Any worktree whose checked-out branch starts with this prefix is treated as a
|
|
99
|
+
# placeholder and shown with a "(free)" tag. `ctree list free` then limits output
|
|
100
|
+
# to only those worktrees. Leave empty to disable the feature.
|
|
101
|
+
# Default: "FREE-"
|
|
102
|
+
free_branch_prefix: "FREE-"
|
|
103
|
+
|
|
104
|
+
# Log verbosity during `ctree create`. 'info' shows one summary line per
|
|
105
|
+
# phase (default). 'debug' shows every per-item action — useful when
|
|
106
|
+
# diagnosing volume or file issues.
|
|
107
|
+
log_level: info
|
|
108
|
+
|
|
109
|
+
# Whether to show the [ctree] prefix in console output. Set to false to
|
|
110
|
+
# remove the prefix from all console messages.
|
|
111
|
+
log_prefix: true
|