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,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