kettle-dev 1.2.3 → 2.0.0

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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +176 -3
  4. data/CITATION.cff +2 -2
  5. data/CONTRIBUTING.md +11 -17
  6. data/README.md +390 -319
  7. data/exe/kettle-dev-setup +12 -63
  8. data/exe/kettle-gh-release +82 -0
  9. data/lib/kettle/dev/gem_spec_reader.rb +2 -2
  10. data/lib/kettle/dev/open_collective_config.rb +12 -0
  11. data/lib/kettle/dev/rakelib/yard.rake +15 -0
  12. data/lib/kettle/dev/tasks/ci_task.rb +4 -4
  13. data/lib/kettle/dev/version.rb +1 -1
  14. data/lib/kettle/dev.rb +4 -12
  15. data/sig/kettle/dev/source_merger.rbs +40 -56
  16. data.tar.gz.sig +0 -0
  17. metadata +15 -144
  18. metadata.gz.sig +0 -0
  19. data/.aiignore.example +0 -19
  20. data/.devcontainer/apt-install/devcontainer-feature.json +0 -9
  21. data/.devcontainer/apt-install/install.sh +0 -11
  22. data/.devcontainer/devcontainer.json +0 -28
  23. data/.env.local.example +0 -31
  24. data/.envrc +0 -47
  25. data/.envrc.example +0 -51
  26. data/.envrc.no-osc.example +0 -51
  27. data/.git-hooks/commit-msg +0 -54
  28. data/.git-hooks/commit-subjects-goalie.txt +0 -8
  29. data/.git-hooks/footer-template.erb.txt +0 -16
  30. data/.git-hooks/prepare-commit-msg +0 -8
  31. data/.github/.codecov.yml.example +0 -14
  32. data/.github/FUNDING.yml +0 -13
  33. data/.github/FUNDING.yml.no-osc.example +0 -13
  34. data/.github/dependabot.yml +0 -13
  35. data/.github/workflows/ancient.yml +0 -83
  36. data/.github/workflows/ancient.yml.example +0 -81
  37. data/.github/workflows/auto-assign.yml +0 -21
  38. data/.github/workflows/codeql-analysis.yml +0 -70
  39. data/.github/workflows/coverage.yml +0 -127
  40. data/.github/workflows/coverage.yml.example +0 -127
  41. data/.github/workflows/current.yml +0 -116
  42. data/.github/workflows/current.yml.example +0 -115
  43. data/.github/workflows/dep-heads.yml +0 -117
  44. data/.github/workflows/dependency-review.yml +0 -20
  45. data/.github/workflows/discord-notifier.yml.example +0 -39
  46. data/.github/workflows/heads.yml +0 -117
  47. data/.github/workflows/heads.yml.example +0 -116
  48. data/.github/workflows/jruby.yml +0 -82
  49. data/.github/workflows/jruby.yml.example +0 -72
  50. data/.github/workflows/legacy.yml +0 -76
  51. data/.github/workflows/license-eye.yml +0 -40
  52. data/.github/workflows/locked_deps.yml +0 -85
  53. data/.github/workflows/opencollective.yml +0 -40
  54. data/.github/workflows/style.yml +0 -67
  55. data/.github/workflows/supported.yml +0 -75
  56. data/.github/workflows/truffle.yml +0 -99
  57. data/.github/workflows/unlocked_deps.yml +0 -84
  58. data/.github/workflows/unsupported.yml +0 -76
  59. data/.gitignore +0 -50
  60. data/.gitlab-ci.yml.example +0 -134
  61. data/.idea/.gitignore +0 -45
  62. data/.junie/guidelines-rbs.md +0 -49
  63. data/.junie/guidelines.md +0 -141
  64. data/.junie/guidelines.md.example +0 -140
  65. data/.licenserc.yaml +0 -7
  66. data/.opencollective.yml +0 -3
  67. data/.opencollective.yml.example +0 -3
  68. data/.qlty/qlty.toml +0 -79
  69. data/.rspec +0 -9
  70. data/.rubocop.yml +0 -13
  71. data/.rubocop_rspec.yml +0 -33
  72. data/.simplecov +0 -16
  73. data/.simplecov.example +0 -11
  74. data/.tool-versions +0 -1
  75. data/.yardignore +0 -13
  76. data/.yardopts +0 -14
  77. data/Appraisal.root.gemfile +0 -10
  78. data/Appraisals +0 -151
  79. data/Appraisals.example +0 -102
  80. data/CHANGELOG.md.example +0 -47
  81. data/CONTRIBUTING.md.example +0 -227
  82. data/FUNDING.md.no-osc.example +0 -63
  83. data/Gemfile +0 -40
  84. data/Gemfile.example +0 -34
  85. data/README.md.example +0 -570
  86. data/README.md.no-osc.example +0 -536
  87. data/Rakefile.example +0 -68
  88. data/gemfiles/modular/coverage.gemfile +0 -6
  89. data/gemfiles/modular/debug.gemfile +0 -13
  90. data/gemfiles/modular/documentation.gemfile +0 -14
  91. data/gemfiles/modular/erb/r2/v3.0.gemfile +0 -1
  92. data/gemfiles/modular/erb/r2.3/default.gemfile +0 -6
  93. data/gemfiles/modular/erb/r2.6/v2.2.gemfile +0 -3
  94. data/gemfiles/modular/erb/r3/v5.0.gemfile +0 -1
  95. data/gemfiles/modular/erb/r3.1/v4.0.gemfile +0 -2
  96. data/gemfiles/modular/erb/vHEAD.gemfile +0 -2
  97. data/gemfiles/modular/injected.gemfile +0 -60
  98. data/gemfiles/modular/mutex_m/r2/v0.3.gemfile +0 -2
  99. data/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile +0 -3
  100. data/gemfiles/modular/mutex_m/r3/v0.3.gemfile +0 -2
  101. data/gemfiles/modular/mutex_m/vHEAD.gemfile +0 -2
  102. data/gemfiles/modular/optional.gemfile +0 -8
  103. data/gemfiles/modular/optional.gemfile.example +0 -5
  104. data/gemfiles/modular/runtime_heads.gemfile +0 -10
  105. data/gemfiles/modular/runtime_heads.gemfile.example +0 -8
  106. data/gemfiles/modular/stringio/r2/v3.0.gemfile +0 -5
  107. data/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile +0 -4
  108. data/gemfiles/modular/stringio/r3/v3.0.gemfile +0 -5
  109. data/gemfiles/modular/stringio/vHEAD.gemfile +0 -2
  110. data/gemfiles/modular/style.gemfile +0 -25
  111. data/gemfiles/modular/style.gemfile.example +0 -25
  112. data/gemfiles/modular/templating.gemfile +0 -3
  113. data/gemfiles/modular/x_std_libs/r2/libs.gemfile +0 -3
  114. data/gemfiles/modular/x_std_libs/r2.3/libs.gemfile +0 -3
  115. data/gemfiles/modular/x_std_libs/r2.4/libs.gemfile +0 -3
  116. data/gemfiles/modular/x_std_libs/r2.6/libs.gemfile +0 -3
  117. data/gemfiles/modular/x_std_libs/r3/libs.gemfile +0 -3
  118. data/gemfiles/modular/x_std_libs/r3.1/libs.gemfile +0 -3
  119. data/gemfiles/modular/x_std_libs/vHEAD.gemfile +0 -3
  120. data/gemfiles/modular/x_std_libs.gemfile +0 -2
  121. data/kettle-dev.gemspec.example +0 -154
  122. data/lib/kettle/dev/modular_gemfiles.rb +0 -119
  123. data/lib/kettle/dev/prism_appraisals.rb +0 -351
  124. data/lib/kettle/dev/prism_gemfile.rb +0 -177
  125. data/lib/kettle/dev/prism_gemspec.rb +0 -284
  126. data/lib/kettle/dev/prism_utils.rb +0 -201
  127. data/lib/kettle/dev/rakelib/install.rake +0 -10
  128. data/lib/kettle/dev/rakelib/template.rake +0 -10
  129. data/lib/kettle/dev/setup_cli.rb +0 -403
  130. data/lib/kettle/dev/source_merger.rb +0 -622
  131. data/lib/kettle/dev/tasks/install_task.rb +0 -553
  132. data/lib/kettle/dev/tasks/template_task.rb +0 -975
  133. data/lib/kettle/dev/template_helpers.rb +0 -685
@@ -1,685 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # External stdlibs
4
- require "find"
5
- require "set"
6
- require "yaml"
7
-
8
- module Kettle
9
- module Dev
10
- # Helpers shared by kettle:dev Rake tasks for templating and file ops.
11
- module TemplateHelpers
12
- # Track results of templating actions across a single process run.
13
- # Keys: absolute destination paths (String)
14
- # Values: Hash with keys: :action (Symbol, one of :create, :replace, :skip, :dir_create, :dir_replace), :timestamp (Time)
15
- @@template_results = {}
16
-
17
- EXECUTABLE_GIT_HOOKS_RE = %r{[\\/]\.git-hooks[\\/](commit-msg|prepare-commit-msg)\z}
18
- # The minimum Ruby supported by setup-ruby GHA
19
- MIN_SETUP_RUBY = Gem::Version.create("2.3")
20
-
21
- TEMPLATE_MANIFEST_PATH = File.expand_path("../../..", __dir__) + "/template_manifest.yml"
22
- RUBY_BASENAMES = %w[Gemfile Rakefile Appraisals Appraisal.root.gemfile .simplecov].freeze
23
- RUBY_SUFFIXES = %w[.gemspec .gemfile].freeze
24
- RUBY_EXTENSIONS = %w[.rb .rake].freeze
25
- @@manifestation = nil
26
-
27
- module_function
28
-
29
- # Root of the host project where Rake was invoked
30
- # @return [String]
31
- def project_root
32
- CIHelpers.project_root
33
- end
34
-
35
- # Root of this gem's checkout (repository root when working from source)
36
- # Calculated relative to lib/kettle/dev/
37
- # @return [String]
38
- def gem_checkout_root
39
- File.expand_path("../../..", __dir__)
40
- end
41
-
42
- # Simple yes/no prompt.
43
- # @param prompt [String]
44
- # @param default [Boolean]
45
- # @return [Boolean]
46
- def ask(prompt, default)
47
- # Force mode: any prompt resolves to Yes when ENV["force"] is set truthy
48
- if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
49
- puts "#{prompt} #{default ? "[Y/n]" : "[y/N]"}: Y (forced)"
50
- return true
51
- end
52
- print("#{prompt} #{default ? "[Y/n]" : "[y/N]"}: ")
53
- ans = Kettle::Dev::InputAdapter.gets&.strip
54
- ans = "" if ans.nil?
55
- # Normalize explicit no first
56
- return false if ans =~ /\An(o)?\z/i
57
- if default
58
- # Empty -> default true; explicit yes -> true; anything else -> false
59
- ans.empty? || ans =~ /\Ay(es)?\z/i
60
- else
61
- # Empty -> default false; explicit yes -> true; others (including garbage) -> false
62
- ans =~ /\Ay(es)?\z/i
63
- end
64
- end
65
-
66
- # Write file content creating directories as needed
67
- # @param dest_path [String]
68
- # @param content [String]
69
- # @return [void]
70
- def write_file(dest_path, content)
71
- FileUtils.mkdir_p(File.dirname(dest_path))
72
- File.open(dest_path, "w") { |f| f.write(content) }
73
- end
74
-
75
- # Prefer an .example variant for a given source path when present
76
- # For a given intended source path (e.g., "/src/Rakefile"), this will return
77
- # "/src/Rakefile.example" if it exists, otherwise returns the original path.
78
- # If the given path already ends with .example, it is returned as-is.
79
- # @param src_path [String]
80
- # @return [String]
81
- def prefer_example(src_path)
82
- return src_path if src_path.end_with?(".example")
83
- example = src_path + ".example"
84
- File.exist?(example) ? example : src_path
85
- end
86
-
87
- # Check if Open Collective is disabled via environment variable.
88
- # Returns true when OPENCOLLECTIVE_HANDLE or FUNDING_ORG is explicitly set to a falsey value.
89
- # @return [Boolean]
90
- def opencollective_disabled?
91
- oc_handle = ENV["OPENCOLLECTIVE_HANDLE"]
92
- funding_org = ENV["FUNDING_ORG"]
93
-
94
- # Check if either variable is explicitly set to false
95
- [oc_handle, funding_org].any? do |val|
96
- val && val.to_s.strip.match(Kettle::Dev::ENV_FALSE_RE)
97
- end
98
- end
99
-
100
- # Prefer a .no-osc.example variant when Open Collective is disabled.
101
- # Otherwise, falls back to prefer_example behavior.
102
- # For a given source path, this will return:
103
- # - "path.no-osc.example" if opencollective_disabled? and it exists
104
- # - Otherwise delegates to prefer_example
105
- # @param src_path [String]
106
- # @return [String]
107
- def prefer_example_with_osc_check(src_path)
108
- if opencollective_disabled?
109
- # Try .no-osc.example first
110
- base = src_path.sub(/\.example\z/, "")
111
- no_osc = base + ".no-osc.example"
112
- return no_osc if File.exist?(no_osc)
113
- end
114
- prefer_example(src_path)
115
- end
116
-
117
- # Check if a file should be skipped when Open Collective is disabled.
118
- # Returns true for opencollective-specific files when opencollective_disabled? is true.
119
- # @param relative_path [String] relative path from gem checkout root
120
- # @return [Boolean]
121
- def skip_for_disabled_opencollective?(relative_path)
122
- return false unless opencollective_disabled?
123
-
124
- opencollective_files = [
125
- ".opencollective.yml",
126
- ".github/workflows/opencollective.yml",
127
- ]
128
-
129
- opencollective_files.include?(relative_path)
130
- end
131
-
132
- # Record a template action for a destination path
133
- # @param dest_path [String]
134
- # @param action [Symbol] one of :create, :replace, :skip, :dir_create, :dir_replace
135
- # @return [void]
136
- def record_template_result(dest_path, action)
137
- abs = File.expand_path(dest_path.to_s)
138
- if action == :skip && @@template_results.key?(abs)
139
- # Preserve the last meaningful action; do not downgrade to :skip
140
- return
141
- end
142
- @@template_results[abs] = {action: action, timestamp: Time.now}
143
- end
144
-
145
- # Access all template results (read-only clone)
146
- # @return [Hash]
147
- def template_results
148
- @@template_results.clone
149
- end
150
-
151
- # Returns true if the given path was created or replaced by the template task in this run
152
- # @param dest_path [String]
153
- # @return [Boolean]
154
- def modified_by_template?(dest_path)
155
- rec = @@template_results[File.expand_path(dest_path.to_s)]
156
- return false unless rec
157
- [:create, :replace, :dir_create, :dir_replace].include?(rec[:action])
158
- end
159
-
160
- # Ensure git working tree is clean before making changes in a task.
161
- # If not a git repo, this is a no-op.
162
- # @param root [String] project root to run git commands in
163
- # @param task_label [String] name of the rake task for user-facing messages (e.g., "kettle:dev:install")
164
- # @return [void]
165
- def ensure_clean_git!(root:, task_label:)
166
- inside_repo = begin
167
- system("git", "-C", root.to_s, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
168
- rescue StandardError => e
169
- Kettle::Dev.debug_error(e, __method__)
170
- false
171
- end
172
- return unless inside_repo
173
-
174
- # Prefer GitAdapter for cleanliness check; fallback to porcelain output
175
- clean = begin
176
- Dir.chdir(root.to_s) { Kettle::Dev::GitAdapter.new.clean? }
177
- rescue StandardError => e
178
- Kettle::Dev.debug_error(e, __method__)
179
- nil
180
- end
181
-
182
- if clean.nil?
183
- # Fallback to using the GitAdapter to get both status and preview
184
- status_output = begin
185
- ga = Kettle::Dev::GitAdapter.new
186
- out, ok = ga.capture(["-C", root.to_s, "status", "--porcelain"]) # adapter can use CLI safely
187
- ok ? out.to_s : ""
188
- rescue StandardError => e
189
- Kettle::Dev.debug_error(e, __method__)
190
- ""
191
- end
192
- return if status_output.strip.empty?
193
- preview = status_output.lines.take(10).map(&:rstrip)
194
- else
195
- return if clean
196
- # For messaging, provide a small preview using GitAdapter even when using the adapter
197
- status_output = begin
198
- ga = Kettle::Dev::GitAdapter.new
199
- out, ok = ga.capture(["-C", root.to_s, "status", "--porcelain"]) # read-only query
200
- ok ? out.to_s : ""
201
- rescue StandardError => e
202
- Kettle::Dev.debug_error(e, __method__)
203
- ""
204
- end
205
- preview = status_output.lines.take(10).map(&:rstrip)
206
- end
207
-
208
- puts "ERROR: Your git working tree has uncommitted changes."
209
- puts "#{task_label} may modify files (e.g., .github/, .gitignore, *.gemspec)."
210
- puts "Please commit or stash your changes, then re-run: rake #{task_label}"
211
- unless preview.empty?
212
- puts "Detected changes:"
213
- preview.each { |l| puts " #{l}" }
214
- puts "(showing up to first 10 lines)"
215
- end
216
- raise Kettle::Dev::Error, "Aborting: git working tree is not clean."
217
- end
218
-
219
- # Copy a single file with interactive prompts for create/replace.
220
- # Yields content for transformation when block given.
221
- # @return [void]
222
- def copy_file_with_prompt(src_path, dest_path, allow_create: true, allow_replace: true)
223
- return unless File.exist?(src_path)
224
-
225
- # Apply optional inclusion filter via ENV["only"] (comma-separated glob patterns relative to project root)
226
- begin
227
- only_raw = ENV["only"].to_s
228
- if !only_raw.empty?
229
- patterns = only_raw.split(",").map { |s| s.strip }.reject(&:empty?)
230
- if !patterns.empty?
231
- proj = project_root.to_s
232
- rel_dest = dest_path.to_s
233
- if rel_dest.start_with?(proj + "/")
234
- rel_dest = rel_dest[(proj.length + 1)..-1]
235
- elsif rel_dest == proj
236
- rel_dest = ""
237
- end
238
- matched = patterns.any? do |pat|
239
- if pat.end_with?("/**")
240
- base = pat[0..-4]
241
- rel_dest == base || rel_dest.start_with?(base + "/")
242
- else
243
- File.fnmatch?(pat, rel_dest, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
244
- end
245
- end
246
- unless matched
247
- record_template_result(dest_path, :skip)
248
- puts "Skipping #{dest_path} (excluded by only filter)"
249
- return
250
- end
251
- end
252
- end
253
- rescue StandardError => e
254
- Kettle::Dev.debug_error(e, __method__)
255
- # If anything goes wrong parsing/matching, ignore the filter and proceed.
256
- end
257
-
258
- dest_exists = File.exist?(dest_path)
259
- action = nil
260
- if dest_exists
261
- if allow_replace
262
- action = ask("Replace #{dest_path}?", true) ? :replace : :skip
263
- else
264
- puts "Skipping #{dest_path} (replace not allowed)."
265
- action = :skip
266
- end
267
- elsif allow_create
268
- action = ask("Create #{dest_path}?", true) ? :create : :skip
269
- else
270
- puts "Skipping #{dest_path} (create not allowed)."
271
- action = :skip
272
- end
273
- if action == :skip
274
- record_template_result(dest_path, :skip)
275
- return
276
- end
277
-
278
- content = File.read(src_path)
279
- content = yield(content) if block_given?
280
- # Replace the explicit template token with the literal "kettle-dev"
281
- # after upstream/template-specific replacements (i.e. after the yield),
282
- # so the token itself is not altered by those replacements.
283
- begin
284
- token = "{KETTLE|DEV|GEM}"
285
- content = content.gsub(token, "kettle-dev") if content.include?(token)
286
- rescue StandardError => e
287
- Kettle::Dev.debug_error(e, __method__)
288
- end
289
-
290
- basename = File.basename(dest_path.to_s)
291
- content = apply_appraisals_merge(content, dest_path) if basename == "Appraisals"
292
- if basename == "Appraisal.root.gemfile" && File.exist?(dest_path)
293
- begin
294
- prior = File.read(dest_path)
295
- content = merge_gemfile_dependencies(content, prior)
296
- rescue StandardError => e
297
- Kettle::Dev.debug_error(e, __method__)
298
- end
299
- end
300
-
301
- # Apply self-dependency removal for all gem-related files
302
- # This ensures we don't introduce a self-dependency when templating
303
- begin
304
- meta = gemspec_metadata
305
- gem_name = meta[:gem_name]
306
- if gem_name && !gem_name.to_s.empty?
307
- content = remove_self_dependency(content, gem_name, dest_path)
308
- end
309
- rescue StandardError => e
310
- Kettle::Dev.debug_error(e, __method__)
311
- # If metadata extraction or removal fails, proceed with content as-is
312
- end
313
-
314
- write_file(dest_path, content)
315
- begin
316
- # Ensure executable bit for git hook scripts when writing under .git-hooks
317
- if EXECUTABLE_GIT_HOOKS_RE =~ dest_path.to_s
318
- File.chmod(0o755, dest_path) if File.exist?(dest_path)
319
- end
320
- rescue StandardError => e
321
- Kettle::Dev.debug_error(e, __method__)
322
- # ignore permission issues
323
- end
324
- record_template_result(dest_path, dest_exists ? :replace : :create)
325
- puts "Wrote #{dest_path}"
326
- end
327
-
328
- # Merge gem dependency lines from a source Gemfile-like content into an existing
329
- # destination Gemfile-like content. Existing gem lines in the destination win;
330
- # we only append missing gem declarations from the source at the end of the file.
331
- # This is deliberately conservative and avoids attempting to relocate gems inside
332
- # group/platform blocks or reconcile version constraints.
333
- # @param src_content [String]
334
- # @param dest_content [String]
335
- # @return [String] merged content
336
- def merge_gemfile_dependencies(src_content, dest_content)
337
- begin
338
- Kettle::Dev::PrismGemfile.merge_gem_calls(src_content.to_s, dest_content.to_s)
339
- rescue StandardError => e
340
- Kettle::Dev.debug_error(e, __method__)
341
- dest_content
342
- end
343
- end
344
-
345
- def apply_appraisals_merge(content, dest_path)
346
- dest = dest_path.to_s
347
- existing = if File.exist?(dest)
348
- File.read(dest)
349
- else
350
- ""
351
- end
352
- Kettle::Dev::PrismAppraisals.merge(content, existing)
353
- rescue StandardError => e
354
- Kettle::Dev.debug_error(e, __method__)
355
- content
356
- end
357
-
358
- # Remove self-referential gem dependencies from content based on file type.
359
- # Applies to gemspec, Gemfile, modular gemfiles, Appraisal.root.gemfile, and Appraisals.
360
- # @param content [String] file content
361
- # @param gem_name [String] the gem name to remove
362
- # @param file_path [String] path to the file (used to determine type)
363
- # @return [String] content with self-dependencies removed
364
- def remove_self_dependency(content, gem_name, file_path)
365
- return content if gem_name.to_s.strip.empty?
366
-
367
- basename = File.basename(file_path.to_s)
368
-
369
- begin
370
- case basename
371
- when /\.gemspec$/
372
- # Use PrismGemspec for gemspec files
373
- Kettle::Dev::PrismGemspec.remove_spec_dependency(content, gem_name)
374
- when "Gemfile", "Appraisal.root.gemfile", /\.gemfile$/
375
- # Use PrismGemfile for Gemfile-like files
376
- Kettle::Dev::PrismGemfile.remove_gem_dependency(content, gem_name)
377
- when "Appraisals"
378
- # Use PrismAppraisals for Appraisals files
379
- Kettle::Dev::PrismAppraisals.remove_gem_dependency(content, gem_name)
380
- else
381
- # Return content unchanged for unknown file types
382
- content
383
- end
384
- rescue StandardError => e
385
- Kettle::Dev.debug_error(e, __method__)
386
- content
387
- end
388
- end
389
-
390
- # Copy a directory tree, prompting before creating or overwriting.
391
- # @return [void]
392
- def copy_dir_with_prompt(src_dir, dest_dir)
393
- return unless Dir.exist?(src_dir)
394
-
395
- # Build a matcher for ENV["only"], relative to project root, that can be reused within this method
396
- only_raw = ENV["only"].to_s
397
- patterns = only_raw.split(",").map { |s| s.strip }.reject(&:empty?) unless only_raw.nil?
398
- patterns ||= []
399
- proj_root = project_root.to_s
400
- matches_only = lambda do |abs_dest|
401
- return true if patterns.empty?
402
- begin
403
- rel_dest = abs_dest.to_s
404
- if rel_dest.start_with?(proj_root + "/")
405
- rel_dest = rel_dest[(proj_root.length + 1)..-1]
406
- elsif rel_dest == proj_root
407
- rel_dest = ""
408
- end
409
- patterns.any? do |pat|
410
- if pat.end_with?("/**")
411
- base = pat[0..-4]
412
- rel_dest == base || rel_dest.start_with?(base + "/")
413
- else
414
- File.fnmatch?(pat, rel_dest, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
415
- end
416
- end
417
- rescue StandardError => e
418
- Kettle::Dev.debug_error(e, __method__)
419
- # On any error, do not filter out (act as matched)
420
- true
421
- end
422
- end
423
-
424
- # Early exit: if an only filter is present and no files inside this directory would match,
425
- # do not prompt to create/replace this directory at all.
426
- begin
427
- if !patterns.empty?
428
- any_match = false
429
- Find.find(src_dir) do |path|
430
- rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
431
- next if rel.empty?
432
- next if File.directory?(path)
433
- target = File.join(dest_dir, rel)
434
- if matches_only.call(target)
435
- any_match = true
436
- break
437
- end
438
- end
439
- unless any_match
440
- record_template_result(dest_dir, :skip)
441
- return
442
- end
443
- end
444
- rescue StandardError => e
445
- Kettle::Dev.debug_error(e, __method__)
446
- # If determining matches fails, fall through to prompting logic
447
- end
448
-
449
- dest_exists = Dir.exist?(dest_dir)
450
- if dest_exists
451
- if ask("Replace directory #{dest_dir} (will overwrite files)?", true)
452
- Find.find(src_dir) do |path|
453
- rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
454
- next if rel.empty?
455
- target = File.join(dest_dir, rel)
456
- if File.directory?(path)
457
- FileUtils.mkdir_p(target)
458
- else
459
- # Per-file inclusion filter
460
- next unless matches_only.call(target)
461
-
462
- FileUtils.mkdir_p(File.dirname(target))
463
- if File.exist?(target)
464
-
465
- # Skip only if contents are identical. If source and target paths are the same,
466
- # avoid FileUtils.cp (which raises) and do an in-place rewrite to satisfy "copy".
467
- begin
468
- if FileUtils.compare_file(path, target)
469
- next
470
- elsif path == target
471
- data = File.binread(path)
472
- File.open(target, "wb") { |f| f.write(data) }
473
- next
474
- end
475
- rescue StandardError => e
476
- Kettle::Dev.debug_error(e, __method__)
477
- # ignore compare errors; fall through to copy
478
- end
479
- end
480
- FileUtils.cp(path, target)
481
- begin
482
- # Ensure executable bit for git hook scripts when copying under .git-hooks
483
- if target.end_with?("/.git-hooks/commit-msg", "/.git-hooks/prepare-commit-msg") ||
484
- EXECUTABLE_GIT_HOOKS_RE =~ target
485
- File.chmod(0o755, target)
486
- end
487
- rescue StandardError => e
488
- Kettle::Dev.debug_error(e, __method__)
489
- # ignore permission issues
490
- end
491
- end
492
- end
493
- puts "Updated #{dest_dir}"
494
- record_template_result(dest_dir, :dir_replace)
495
- else
496
- puts "Skipped #{dest_dir}"
497
- record_template_result(dest_dir, :skip)
498
- end
499
- elsif ask("Create directory #{dest_dir}?", true)
500
- FileUtils.mkdir_p(dest_dir)
501
- Find.find(src_dir) do |path|
502
- rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
503
- next if rel.empty?
504
- target = File.join(dest_dir, rel)
505
- if File.directory?(path)
506
- FileUtils.mkdir_p(target)
507
- else
508
- # Per-file inclusion filter
509
- next unless matches_only.call(target)
510
-
511
- FileUtils.mkdir_p(File.dirname(target))
512
- if File.exist?(target)
513
- # Skip only if contents are identical. If source and target paths are the same,
514
- # avoid FileUtils.cp (which raises) and do an in-place rewrite to satisfy "copy".
515
- begin
516
- if FileUtils.compare_file(path, target)
517
- next
518
- elsif path == target
519
- data = File.binread(path)
520
- File.open(target, "wb") { |f| f.write(data) }
521
- next
522
- end
523
- rescue StandardError => e
524
- Kettle::Dev.debug_error(e, __method__)
525
- # ignore compare errors; fall through to copy
526
- end
527
- end
528
- FileUtils.cp(path, target)
529
- begin
530
- # Ensure executable bit for git hook scripts when copying under .git-hooks
531
- if target.end_with?("/.git-hooks/commit-msg", "/.git-hooks/prepare-commit-msg") ||
532
- EXECUTABLE_GIT_HOOKS_RE =~ target
533
- File.chmod(0o755, target)
534
- end
535
- rescue StandardError => e
536
- Kettle::Dev.debug_error(e, __method__)
537
- # ignore permission issues
538
- end
539
- end
540
- end
541
- puts "Created #{dest_dir}"
542
- record_template_result(dest_dir, :dir_create)
543
- end
544
- end
545
-
546
- # Apply common token replacements used when templating text files
547
- # @param content [String]
548
- # @param org [String, nil]
549
- # @param gem_name [String]
550
- # @param namespace [String]
551
- # @param namespace_shield [String]
552
- # @param gem_shield [String]
553
- # @param funding_org [String, nil]
554
- # @return [String]
555
- def apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:, funding_org: nil, min_ruby: nil)
556
- raise Error, "Org could not be derived" unless org && !org.empty?
557
- raise Error, "Gem name could not be derived" unless gem_name && !gem_name.empty?
558
-
559
- funding_org ||= org
560
- # Derive min_ruby if not provided
561
- mr = begin
562
- meta = gemspec_metadata
563
- meta[:min_ruby]
564
- rescue StandardError => e
565
- Kettle::Dev.debug_error(e, __method__)
566
- # leave min_ruby as-is (possibly nil)
567
- end
568
- if min_ruby.nil? || min_ruby.to_s.strip.empty?
569
- min_ruby = mr.respond_to?(:to_s) ? mr.to_s : mr
570
- end
571
-
572
- # Derive min_dev_ruby from min_ruby
573
- # min_dev_ruby is the greater of min_dev_ruby and ruby 2.3,
574
- # because ruby 2.3 is the minimum ruby supported by setup-ruby GHA
575
- min_dev_ruby = begin
576
- [mr, MIN_SETUP_RUBY].max
577
- rescue StandardError => e
578
- Kettle::Dev.debug_error(e, __method__)
579
- MIN_SETUP_RUBY
580
- end
581
-
582
- c = content.dup
583
- c = c.gsub("kettle-rb", org.to_s)
584
- c = c.gsub("{OPENCOLLECTIVE|ORG_NAME}", funding_org || "opencollective")
585
- # Replace min ruby token if present
586
- begin
587
- if min_ruby && !min_ruby.to_s.empty? && c.include?("{K_D_MIN_RUBY}")
588
- c = c.gsub("{K_D_MIN_RUBY}", min_ruby.to_s)
589
- end
590
- rescue StandardError => e
591
- Kettle::Dev.debug_error(e, __method__)
592
- # ignore
593
- end
594
-
595
- # Replace min ruby dev token if present
596
- begin
597
- if min_dev_ruby && !min_dev_ruby.to_s.empty? && c.include?("{K_D_MIN_DEV_RUBY}")
598
- c = c.gsub("{K_D_MIN_DEV_RUBY}", min_dev_ruby.to_s)
599
- end
600
- rescue StandardError => e
601
- Kettle::Dev.debug_error(e, __method__)
602
- # ignore
603
- end
604
-
605
- # Replace target gem name token if present
606
- begin
607
- token = "{TARGET|GEM|NAME}"
608
- c = c.gsub(token, gem_name) if c.include?(token)
609
- rescue StandardError => e
610
- Kettle::Dev.debug_error(e, __method__)
611
- # If replacement fails unexpectedly, proceed with content as-is
612
- end
613
-
614
- # Special-case: yard-head link uses the gem name as a subdomain and must be dashes-only.
615
- # Apply this BEFORE other generic replacements so it isn't altered incorrectly.
616
- begin
617
- dashed = gem_name.tr("_", "-")
618
- c = c.gsub("[🚎yard-head]: https://kettle-dev.galtzo.com", "[🚎yard-head]: https://#{dashed}.galtzo.com")
619
- rescue StandardError => e
620
- Kettle::Dev.debug_error(e, __method__)
621
- # ignore
622
- end
623
-
624
- # Replace occurrences of the literal template gem name ("kettle-dev")
625
- # with the destination gem name.
626
- c = c.gsub("kettle-dev", gem_name)
627
- c = c.gsub(/\bKettle::Dev\b/u, namespace) unless namespace.empty?
628
- c = c.gsub("Kettle%3A%3ADev", namespace_shield) unless namespace_shield.empty?
629
- c = c.gsub("kettle--dev", gem_shield)
630
- # Replace require and path structures with gem_name, modifying - to / if needed
631
- c.gsub("kettle/dev", gem_name.tr("-", "/"))
632
- end
633
-
634
- # Parse gemspec metadata and derive useful strings
635
- # @param root [String] project root
636
- # @return [Hash]
637
- def gemspec_metadata(root = project_root)
638
- Kettle::Dev::GemSpecReader.load(root)
639
- end
640
-
641
- def apply_strategy(content, dest_path)
642
- return content unless ruby_template?(dest_path)
643
- strategy = strategy_for(dest_path)
644
- dest_content = File.exist?(dest_path) ? File.read(dest_path) : ""
645
- Kettle::Dev::SourceMerger.apply(strategy: strategy, src: content, dest: dest_content, path: rel_path(dest_path))
646
- end
647
-
648
- def manifestation
649
- @@manifestation ||= load_manifest
650
- end
651
-
652
- def strategy_for(dest_path)
653
- relative = rel_path(dest_path)
654
- manifestation.find do |entry|
655
- File.fnmatch?(entry[:path], relative, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
656
- end&.fetch(:strategy, :skip) || :skip
657
- end
658
-
659
- def rel_path(path)
660
- project = project_root.to_s
661
- path.to_s.sub(/^#{Regexp.escape(project)}\/?/, "")
662
- end
663
-
664
- def ruby_template?(dest_path)
665
- base = File.basename(dest_path.to_s)
666
- return true if RUBY_BASENAMES.include?(base)
667
- return true if RUBY_SUFFIXES.any? { |suffix| base.end_with?(suffix) }
668
- ext = File.extname(base)
669
- RUBY_EXTENSIONS.include?(ext)
670
- end
671
-
672
- def load_manifest
673
- raw = YAML.load_file(TEMPLATE_MANIFEST_PATH)
674
- raw.map do |entry|
675
- {
676
- path: entry["path"],
677
- strategy: entry["strategy"].to_s.strip.downcase.to_sym,
678
- }
679
- end
680
- rescue Errno::ENOENT
681
- []
682
- end
683
- end
684
- end
685
- end