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,975 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Kettle
4
- module Dev
5
- module Tasks
6
- # Thin wrapper to expose the kettle:dev:template task logic as a callable API
7
- # for testability. The rake task should only call this method.
8
- module TemplateTask
9
- MODULAR_GEMFILE_DIR = "gemfiles/modular"
10
-
11
- module_function
12
-
13
- # Ensure every Markdown atx-style heading line has exactly one blank line
14
- # before and after, skipping content inside fenced code blocks.
15
- def normalize_heading_spacing(text)
16
- lines = text.split("\n", -1)
17
- out = []
18
- in_fence = false
19
- fence_re = /^\s*```/
20
- heading_re = /^\s*#+\s+.+/
21
- lines.each_with_index do |ln, idx|
22
- if ln =~ fence_re
23
- in_fence = !in_fence
24
- out << ln
25
- next
26
- end
27
- if !in_fence && ln =~ heading_re
28
- prev_blank = out.empty? ? false : out.last.to_s.strip == ""
29
- out << "" unless out.empty? || prev_blank
30
- out << ln
31
- nxt = lines[idx + 1]
32
- out << "" unless nxt.to_s.strip == ""
33
- else
34
- out << ln
35
- end
36
- end
37
- # Collapse accidental multiple blanks
38
- collapsed = []
39
- out.each do |l|
40
- if l.strip == "" && collapsed.last.to_s.strip == ""
41
- next
42
- end
43
- collapsed << l
44
- end
45
- collapsed.join("\n")
46
- end
47
-
48
- # Abort wrapper that avoids terminating the entire process during specs
49
- def task_abort(msg)
50
- raise Kettle::Dev::Error, msg
51
- end
52
-
53
- # Execute the template operation into the current project.
54
- # All options/IO are controlled via TemplateHelpers and ENV.
55
- def run
56
- # Inline the former rake task body, but using helpers directly.
57
- helpers = Kettle::Dev::TemplateHelpers
58
-
59
- project_root = helpers.project_root
60
- gem_checkout_root = helpers.gem_checkout_root
61
-
62
- # Ensure git working tree is clean before making changes (when run standalone)
63
- helpers.ensure_clean_git!(root: project_root, task_label: "kettle:dev:template")
64
-
65
- meta = helpers.gemspec_metadata(project_root)
66
- gem_name = meta[:gem_name]
67
- min_ruby = meta[:min_ruby]
68
- forge_org = meta[:forge_org] || meta[:gh_org]
69
- funding_org = helpers.opencollective_disabled? ? nil : meta[:funding_org] || forge_org
70
- entrypoint_require = meta[:entrypoint_require]
71
- namespace = meta[:namespace]
72
- namespace_shield = meta[:namespace_shield]
73
- gem_shield = meta[:gem_shield]
74
-
75
- # 1) .devcontainer directory
76
- helpers.copy_dir_with_prompt(File.join(gem_checkout_root, ".devcontainer"), File.join(project_root, ".devcontainer"))
77
-
78
- # 2) .github/**/*.yml with FUNDING.yml customizations
79
- source_github_dir = File.join(gem_checkout_root, ".github")
80
- if Dir.exist?(source_github_dir)
81
- # Build a unique set of logical .yml paths, preferring the .example variant when present
82
- candidates = Dir.glob(File.join(source_github_dir, "**", "*.yml")) +
83
- Dir.glob(File.join(source_github_dir, "**", "*.yml.example"))
84
- selected = {}
85
- candidates.each do |path|
86
- # Key by the path without the optional .example suffix
87
- key = path.sub(/\.example\z/, "")
88
- # Prefer example: overwrite a plain selection with .example, but do not downgrade
89
- if path.end_with?(".example")
90
- selected[key] = path
91
- else
92
- selected[key] ||= path
93
- end
94
- end
95
- # Parse optional include patterns (comma-separated globs relative to project root)
96
- include_raw = ENV["include"].to_s
97
- include_patterns = include_raw.split(",").map { |s| s.strip }.reject(&:empty?)
98
- matches_include = lambda do |abs_dest|
99
- return false if include_patterns.empty?
100
- begin
101
- rel_dest = abs_dest.to_s
102
- proj = project_root.to_s
103
- if rel_dest.start_with?(proj + "/")
104
- rel_dest = rel_dest[(proj.length + 1)..-1]
105
- elsif rel_dest == proj
106
- rel_dest = ""
107
- end
108
- include_patterns.any? do |pat|
109
- if pat.end_with?("/**")
110
- base = pat[0..-4]
111
- rel_dest == base || rel_dest.start_with?(base + "/")
112
- else
113
- File.fnmatch?(pat, rel_dest, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
114
- end
115
- end
116
- rescue StandardError => e
117
- Kettle::Dev.debug_error(e, __method__)
118
- false
119
- end
120
- end
121
-
122
- selected.values.each do |orig_src|
123
- src = helpers.prefer_example_with_osc_check(orig_src)
124
- # Destination path should never include the .example suffix.
125
- rel = orig_src.sub(/^#{Regexp.escape(gem_checkout_root)}\/?/, "").sub(/\.example\z/, "")
126
- dest = File.join(project_root, rel)
127
-
128
- # Skip opencollective-specific files when Open Collective is disabled
129
- if helpers.skip_for_disabled_opencollective?(rel)
130
- puts "Skipping #{rel} (Open Collective disabled)"
131
- next
132
- end
133
-
134
- # Optional file: .github/workflows/discord-notifier.yml should NOT be copied by default.
135
- # Only copy when --include matches it.
136
- if rel == ".github/workflows/discord-notifier.yml"
137
- unless matches_include.call(dest)
138
- # Explicitly skip without prompting
139
- next
140
- end
141
- end
142
-
143
- if File.basename(rel) == "FUNDING.yml"
144
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
145
- c = content.dup
146
- # Effective funding handle should fall back to forge_org when funding_org is nil.
147
- # This allows tests to stub FUNDING_ORG=false to bypass explicit funding detection
148
- # while still templating the line with the derived organization (e.g., from homepage URL).
149
- effective_funding = funding_org || forge_org
150
- c = if helpers.opencollective_disabled?
151
- c.gsub(/^open_collective:\s+.*$/i) { |line| "open_collective: # Replace with a single Open Collective username" }
152
- else
153
- c.gsub(/^open_collective:\s+.*$/i) { |line| effective_funding ? "open_collective: #{effective_funding}" : line }
154
- end
155
- if gem_name && !gem_name.empty?
156
- c = c.gsub(/^tidelift:\s+.*$/i, "tidelift: rubygems/#{gem_name}")
157
- end
158
- helpers.apply_common_replacements(
159
- c,
160
- org: forge_org,
161
- funding_org: effective_funding, # pass effective funding for downstream tokens
162
- gem_name: gem_name,
163
- namespace: namespace,
164
- namespace_shield: namespace_shield,
165
- gem_shield: gem_shield,
166
- min_ruby: min_ruby,
167
- )
168
- end
169
- else
170
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
171
- helpers.apply_common_replacements(
172
- content,
173
- org: forge_org,
174
- funding_org: funding_org,
175
- gem_name: gem_name,
176
- namespace: namespace,
177
- namespace_shield: namespace_shield,
178
- gem_shield: gem_shield,
179
- min_ruby: min_ruby,
180
- )
181
- end
182
- end
183
- end
184
- end
185
-
186
- # 3) .qlty/qlty.toml
187
- helpers.copy_file_with_prompt(
188
- helpers.prefer_example(File.join(gem_checkout_root, ".qlty/qlty.toml")),
189
- File.join(project_root, ".qlty/qlty.toml"),
190
- allow_create: true,
191
- allow_replace: true,
192
- )
193
-
194
- # 4) gemfiles/modular/* and nested directories (delegated for DRYness)
195
- Kettle::Dev::ModularGemfiles.sync!(
196
- helpers: helpers,
197
- project_root: project_root,
198
- gem_checkout_root: gem_checkout_root,
199
- min_ruby: min_ruby,
200
- )
201
-
202
- # 5) spec/spec_helper.rb (no create)
203
- dest_spec_helper = File.join(project_root, "spec/spec_helper.rb")
204
- if File.file?(dest_spec_helper)
205
- old = File.read(dest_spec_helper)
206
- if old.include?('require "kettle/dev"') || old.include?("require 'kettle/dev'")
207
- replacement = %(require "#{entrypoint_require}")
208
- new_content = old.gsub(/require\s+["']kettle\/dev["']/, replacement)
209
- if new_content != old
210
- if helpers.ask("Replace require \"kettle/dev\" in spec/spec_helper.rb with #{replacement}?", true)
211
- helpers.write_file(dest_spec_helper, new_content)
212
- puts "Updated require in spec/spec_helper.rb"
213
- else
214
- puts "Skipped modifying spec/spec_helper.rb"
215
- end
216
- end
217
- end
218
- end
219
-
220
- # 6) .env.local special case: never read or touch .env.local from source; only copy .env.local.example to .env.local.example
221
- begin
222
- envlocal_src = File.join(gem_checkout_root, ".env.local.example")
223
- envlocal_dest = File.join(project_root, ".env.local.example")
224
- if File.exist?(envlocal_src)
225
- helpers.copy_file_with_prompt(envlocal_src, envlocal_dest, allow_create: true, allow_replace: true)
226
- end
227
- rescue StandardError => e
228
- Kettle::Dev.debug_error(e, __method__)
229
- puts "WARNING: Skipped .env.local example copy due to #{e.class}: #{e.message}"
230
- end
231
-
232
- # 7) Root and other files
233
- # 7a) Special-case: gemspec example must be renamed to destination gem's name
234
- begin
235
- # Prefer the .example variant when present
236
- gemspec_template_src = helpers.prefer_example(File.join(gem_checkout_root, "kettle-dev.gemspec"))
237
- if File.exist?(gemspec_template_src)
238
- dest_gemspec = if gem_name && !gem_name.to_s.empty?
239
- File.join(project_root, "#{gem_name}.gemspec")
240
- else
241
- # Fallback rules:
242
- # 1) Prefer any existing gemspec in the destination project
243
- existing = Dir.glob(File.join(project_root, "*.gemspec")).sort.first
244
- if existing
245
- existing
246
- else
247
- # 2) If none, use the example file's name with ".example" removed
248
- fallback_name = File.basename(gemspec_template_src).sub(/\.example\z/, "")
249
- File.join(project_root, fallback_name)
250
- end
251
- end
252
-
253
- # If a destination gemspec already exists, get metadata from GemSpecReader via helpers
254
- orig_meta = nil
255
- dest_existed = File.exist?(dest_gemspec)
256
- if dest_existed
257
- begin
258
- orig_meta = helpers.gemspec_metadata(File.dirname(dest_gemspec))
259
- rescue StandardError => e
260
- Kettle::Dev.debug_error(e, __method__)
261
- orig_meta = nil
262
- end
263
- end
264
-
265
- helpers.copy_file_with_prompt(gemspec_template_src, dest_gemspec, allow_create: true, allow_replace: true) do |content|
266
- # First apply standard replacements from the template example, but only
267
- # when we have a usable gem_name. If gem_name is unknown, leave content as-is
268
- # to allow filename fallback behavior without raising.
269
- c = if gem_name && !gem_name.to_s.empty?
270
- helpers.apply_common_replacements(
271
- content,
272
- org: forge_org,
273
- funding_org: funding_org,
274
- gem_name: gem_name,
275
- namespace: namespace,
276
- namespace_shield: namespace_shield,
277
- gem_shield: gem_shield,
278
- min_ruby: min_ruby,
279
- )
280
- else
281
- content.dup
282
- end
283
-
284
- if orig_meta
285
- # Build replacements using AST-aware helper to carry over fields
286
- repl = {}
287
- if (name = orig_meta[:gem_name]) && !name.to_s.empty?
288
- repl[:name] = name.to_s
289
- end
290
- repl[:authors] = Array(orig_meta[:authors]).map(&:to_s) if orig_meta[:authors]
291
- repl[:email] = Array(orig_meta[:email]).map(&:to_s) if orig_meta[:email]
292
- repl[:summary] = orig_meta[:summary].to_s if orig_meta[:summary]
293
- repl[:description] = orig_meta[:description].to_s if orig_meta[:description]
294
- repl[:licenses] = Array(orig_meta[:licenses]).map(&:to_s) if orig_meta[:licenses]
295
- if orig_meta[:required_ruby_version]
296
- repl[:required_ruby_version] = orig_meta[:required_ruby_version].to_s
297
- end
298
- repl[:require_paths] = Array(orig_meta[:require_paths]).map(&:to_s) if orig_meta[:require_paths]
299
- repl[:bindir] = orig_meta[:bindir].to_s if orig_meta[:bindir]
300
- repl[:executables] = Array(orig_meta[:executables]).map(&:to_s) if orig_meta[:executables]
301
-
302
- begin
303
- c = Kettle::Dev::PrismGemspec.replace_gemspec_fields(c, repl)
304
- rescue StandardError => e
305
- Kettle::Dev.debug_error(e, __method__)
306
- # Best-effort carry-over; ignore failure and keep c as-is
307
- end
308
- end
309
-
310
- # Ensure we do not introduce a self-dependency when templating the gemspec.
311
- # If the template included a dependency on the template gem (e.g., "kettle-dev"),
312
- # the common replacements would have turned it into the destination gem's name.
313
- # Strip any dependency lines that name the destination gem.
314
- begin
315
- if gem_name && !gem_name.to_s.empty?
316
- begin
317
- c = Kettle::Dev::PrismGemspec.remove_spec_dependency(c, gem_name)
318
- rescue StandardError => e
319
- Kettle::Dev.debug_error(e, __method__)
320
- end
321
- end
322
- rescue StandardError => e
323
- Kettle::Dev.debug_error(e, __method__)
324
- # If anything goes wrong, keep the content as-is rather than failing the task
325
- end
326
-
327
- if dest_existed
328
- begin
329
- merged = helpers.apply_strategy(c, dest_gemspec)
330
- c = merged if merged.is_a?(String) && !merged.empty?
331
- rescue StandardError => e
332
- Kettle::Dev.debug_error(e, __method__)
333
- end
334
- end
335
-
336
- c
337
- end
338
- end
339
- rescue StandardError => e
340
- Kettle::Dev.debug_error(e, __method__)
341
- # Do not fail the entire template task if gemspec copy has issues
342
- end
343
-
344
- files_to_copy = %w[
345
- .aiignore
346
- .envrc
347
- .gitignore
348
- .idea/.gitignore
349
- .gitlab-ci.yml
350
- .junie/guidelines-rbs.md
351
- .junie/guidelines.md
352
- .licenserc.yaml
353
- .opencollective.yml
354
- .rspec
355
- .rubocop.yml
356
- .rubocop_rspec.yml
357
- .simplecov
358
- .tool-versions
359
- .yardopts
360
- .yardignore
361
- Appraisal.root.gemfile
362
- Appraisals
363
- CHANGELOG.md
364
- CITATION.cff
365
- CODE_OF_CONDUCT.md
366
- CONTRIBUTING.md
367
- FUNDING.md
368
- Gemfile
369
- README.md
370
- RUBOCOP.md
371
- Rakefile
372
- SECURITY.md
373
- ]
374
-
375
- # Snapshot existing README content once (for H1 prefix preservation after write)
376
- existing_readme_before = begin
377
- path = File.join(project_root, "README.md")
378
- File.file?(path) ? File.read(path) : nil
379
- rescue StandardError => e
380
- Kettle::Dev.debug_error(e, __method__)
381
- nil
382
- end
383
-
384
- files_to_copy.each do |rel|
385
- # Skip opencollective-specific files when Open Collective is disabled
386
- if helpers.skip_for_disabled_opencollective?(rel)
387
- puts "Skipping #{rel} (Open Collective disabled)"
388
- next
389
- end
390
-
391
- src = helpers.prefer_example_with_osc_check(File.join(gem_checkout_root, rel))
392
- dest = File.join(project_root, rel)
393
- next unless File.exist?(src)
394
-
395
- if File.basename(rel) == "README.md"
396
- # Precompute destination README H1 prefix (emoji(s) or first grapheme) before any overwrite occurs
397
- prev_readme = File.exist?(dest) ? File.read(dest) : nil
398
- begin
399
- if prev_readme
400
- first_h1_prev = prev_readme.lines.find { |ln| ln =~ /^#\s+/ }
401
- if first_h1_prev
402
- emoji_re = Kettle::EmojiRegex::REGEX
403
- tail = first_h1_prev.sub(/^#\s+/, "")
404
- # Extract consecutive leading emoji graphemes
405
- out = +""
406
- s = tail.dup
407
- loop do
408
- cluster = s[/\A\X/u]
409
- break if cluster.nil? || cluster.empty?
410
-
411
- if emoji_re =~ cluster
412
- out << cluster
413
- s = s[cluster.length..-1].to_s
414
- else
415
- break
416
- end
417
- end
418
- if !out.empty?
419
- out
420
- else
421
- # Fallback to first grapheme
422
- tail[/\A\X/u]
423
- end
424
- end
425
- end
426
- rescue StandardError => e
427
- Kettle::Dev.debug_error(e, __method__)
428
- # ignore, leave dest_preserve_prefix as nil
429
- end
430
-
431
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
432
- # 1) Do token replacements on the template content (org/gem/namespace/shields)
433
- c = helpers.apply_common_replacements(
434
- content,
435
- org: forge_org,
436
- funding_org: funding_org,
437
- gem_name: gem_name,
438
- namespace: namespace,
439
- namespace_shield: namespace_shield,
440
- gem_shield: gem_shield,
441
- min_ruby: min_ruby,
442
- )
443
-
444
- # 2) Merge specific sections from destination README, if present
445
- begin
446
- dest_existing = prev_readme
447
-
448
- # Parse Markdown headings while ignoring fenced code blocks (``` ... ```)
449
- build_sections = lambda do |md|
450
- return {lines: [], sections: [], line_count: 0} unless md
451
-
452
- lines = md.split("\n", -1)
453
- line_count = lines.length
454
-
455
- sections = []
456
- in_code = false
457
- fence_re = /^\s*```/ # start or end of fenced block
458
-
459
- lines.each_with_index do |ln, i|
460
- if ln =~ fence_re
461
- in_code = !in_code
462
- next
463
- end
464
- next if in_code
465
-
466
- if (m = ln.match(/^(#+)\s+.+/))
467
- level = m[1].length
468
- title = ln.sub(/^#+\s+/, "")
469
- base = title.sub(/\A[^\p{Alnum}]+/u, "").strip.downcase
470
- sections << {start: i, level: level, heading: ln, base: base}
471
- end
472
- end
473
-
474
- # Compute stop indices based on next heading of same or higher level
475
- sections.each_with_index do |sec, i|
476
- j = i + 1
477
- stop = line_count - 1
478
- while j < sections.length
479
- if sections[j][:level] <= sec[:level]
480
- stop = sections[j][:start] - 1
481
- break
482
- end
483
- j += 1
484
- end
485
- sec[:stop_to_next_any] = stop
486
- body_lines_any = lines[(sec[:start] + 1)..stop] || []
487
- sec[:body_to_next_any] = body_lines_any.join("\n")
488
- end
489
-
490
- {lines: lines, sections: sections, line_count: line_count}
491
- end
492
-
493
- # Helper: Compute the branch end (inclusive) for a section at index i
494
- branch_end_index = lambda do |sections_arr, i, total_lines|
495
- current = sections_arr[i]
496
- j = i + 1
497
- while j < sections_arr.length
498
- return sections_arr[j][:start] - 1 if sections_arr[j][:level] <= current[:level]
499
-
500
- j += 1
501
- end
502
- total_lines - 1
503
- end
504
-
505
- src_parsed = build_sections.call(c)
506
- dest_parsed = build_sections.call(dest_existing)
507
-
508
- # Build lookup for destination sections by base title, using full branch body (to next heading of same or higher level)
509
- dest_lookup = {}
510
- if dest_parsed && dest_parsed[:sections]
511
- dest_parsed[:sections].each_with_index do |s, idx|
512
- base = s[:base]
513
- # Only set once (first occurrence wins)
514
- next if dest_lookup.key?(base)
515
-
516
- be = branch_end_index.call(dest_parsed[:sections], idx, dest_parsed[:line_count])
517
- body_lines = dest_parsed[:lines][(s[:start] + 1)..be] || []
518
- dest_lookup[base] = {body_branch: body_lines.join("\n"), level: s[:level]}
519
- end
520
- end
521
-
522
- # Build targets to merge: existing curated list plus any NOTE sections at any level
523
- note_bases = []
524
- if src_parsed && src_parsed[:sections]
525
- note_bases = src_parsed[:sections]
526
- .select { |s| s[:heading] =~ /^#+\s+note:.*/i }
527
- .map { |s| s[:base] }
528
- end
529
- targets = ["synopsis", "configuration", "basic usage"] + note_bases
530
-
531
- # Replace matching sections in src using full branch ranges
532
- if src_parsed && src_parsed[:sections] && !src_parsed[:sections].empty?
533
- lines = src_parsed[:lines].dup
534
- # Iterate in reverse to keep indices valid
535
- src_parsed[:sections].reverse_each.with_index do |sec, rev_i|
536
- next unless targets.include?(sec[:base])
537
-
538
- # Determine branch range in src for this section
539
- # rev_i is reverse index; compute forward index
540
- i = src_parsed[:sections].length - 1 - rev_i
541
- src_end = branch_end_index.call(src_parsed[:sections], i, src_parsed[:line_count])
542
- dest_entry = dest_lookup[sec[:base]]
543
- new_body = dest_entry ? dest_entry[:body_branch] : "\n\n"
544
- new_block = [sec[:heading], new_body].join("\n")
545
- range_start = sec[:start]
546
- range_end = src_end
547
- # Remove old range
548
- lines.slice!(range_start..range_end)
549
- # Insert new block (split preserves potential empty tail)
550
- insert_lines = new_block.split("\n", -1)
551
- lines.insert(range_start, *insert_lines)
552
- end
553
- c = lines.join("\n")
554
- end
555
-
556
- # 3) Preserve entire H1 line from destination README, if any
557
- begin
558
- if dest_existing
559
- dest_h1 = dest_existing.lines.find { |ln| ln =~ /^#\s+/ }
560
- if dest_h1
561
- lines_new = c.split("\n", -1)
562
- src_h1_idx = lines_new.index { |ln| ln =~ /^#\s+/ }
563
- if src_h1_idx
564
- # Replace the entire H1 line with the destination's H1 exactly
565
- lines_new[src_h1_idx] = dest_h1.chomp
566
- c = lines_new.join("\n")
567
- end
568
- end
569
- end
570
- rescue StandardError => e
571
- Kettle::Dev.debug_error(e, __method__)
572
- # ignore H1 preservation errors
573
- end
574
- rescue StandardError => e
575
- Kettle::Dev.debug_error(e, __method__)
576
- # Best effort; if anything fails, keep c as-is
577
- end
578
-
579
- c
580
- end
581
- elsif ["CHANGELOG.md", "CITATION.cff", "CONTRIBUTING.md", ".opencollective.yml", "FUNDING.md", ".junie/guidelines.md", ".envrc"].include?(rel)
582
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
583
- c = helpers.apply_common_replacements(
584
- content,
585
- org: forge_org,
586
- funding_org: funding_org,
587
- gem_name: gem_name,
588
- namespace: namespace,
589
- namespace_shield: namespace_shield,
590
- gem_shield: gem_shield,
591
- min_ruby: min_ruby,
592
- )
593
- if File.basename(rel) == "CHANGELOG.md"
594
- begin
595
- # Special handling for CHANGELOG.md
596
- # 1) Take template header through Unreleased section (inclusive)
597
- src_lines = c.split("\n", -1)
598
- tpl_unrel_idx = src_lines.index { |ln| ln =~ /^##\s*\[\s*Unreleased\s*\]/i }
599
- if tpl_unrel_idx
600
- # Find end of Unreleased in template (next ## or # heading)
601
- tpl_end_idx = src_lines.length - 1
602
- j = tpl_unrel_idx + 1
603
- while j < src_lines.length
604
- if src_lines[j] =~ /^##\s+\[/ || src_lines[j] =~ /^#\s+/ || src_lines[j] =~ /^##\s+[^\[]/
605
- tpl_end_idx = j - 1
606
- break
607
- end
608
- j += 1
609
- end
610
- tpl_header_pre = src_lines[0...tpl_unrel_idx] # lines before Unreleased heading
611
- tpl_unrel_heading = src_lines[tpl_unrel_idx]
612
- src_lines[(tpl_unrel_idx + 1)..tpl_end_idx] || []
613
-
614
- # 2) Extract destination Unreleased content, preserving list items under any standard headings
615
- dest_content = File.file?(dest) ? File.read(dest) : ""
616
- dest_lines = dest_content.split("\n", -1)
617
- dest_unrel_idx = dest_lines.index { |ln| ln =~ /^##\s*\[\s*Unreleased\s*\]/i }
618
- dest_end_idx = if dest_unrel_idx
619
- k = dest_unrel_idx + 1
620
- e = dest_lines.length - 1
621
- while k < dest_lines.length
622
- if dest_lines[k] =~ /^##\s+\[/ || dest_lines[k] =~ /^#\s+/ || dest_lines[k] =~ /^##\s+[^\[]/
623
- e = k - 1
624
- break
625
- end
626
- k += 1
627
- end
628
- e
629
- end
630
- dest_unrel_body = dest_unrel_idx ? (dest_lines[(dest_unrel_idx + 1)..dest_end_idx] || []) : []
631
-
632
- # Helper: parse body into map of heading=>items (only '- ' markdown items)
633
- std_heads = [
634
- "### Added",
635
- "### Changed",
636
- "### Deprecated",
637
- "### Removed",
638
- "### Fixed",
639
- "### Security",
640
- ]
641
-
642
- parse_items = lambda do |body_lines|
643
- result = {}
644
- cur = nil
645
- i = 0
646
- while i < body_lines.length
647
- ln = body_lines[i]
648
- if ln.start_with?("### ")
649
- cur = ln.strip
650
- result[cur] ||= []
651
- i += 1
652
- next
653
- end
654
-
655
- # Detect a list item bullet (allow optional indentation)
656
- if (m = ln.match(/^(\s*)[-*]\s/))
657
- result[cur] ||= []
658
- base_indent = m[1].length
659
- # Start a new item: include the bullet line
660
- result[cur] << ln.rstrip
661
- i += 1
662
-
663
- # Include subsequent lines that belong to this list item:
664
- # - blank lines
665
- # - lines with indentation greater than the bullet's indentation
666
- # - any lines inside fenced code blocks (```), regardless of indentation until fence closes
667
- in_fence = false
668
- fence_re = /^\s*```/
669
- while i < body_lines.length
670
- l2 = body_lines[i]
671
- # Stop if next sibling/top-level bullet of same or smaller indent and not inside a fence
672
- if !in_fence && l2 =~ /^(\s*)[-*]\s/
673
- ind = Regexp.last_match(1).length
674
- break if ind <= base_indent
675
- end
676
- # Break if a new section heading appears and we're not in a fence
677
- break if !in_fence && l2.start_with?("### ")
678
-
679
- if l2 =~ fence_re
680
- in_fence = !in_fence
681
- result[cur] << l2.rstrip
682
- i += 1
683
- next
684
- end
685
-
686
- # Include blanks and lines indented more than base indent, or anything while in fence
687
- if in_fence || l2.strip.empty? || (l2[/^\s*/].length > base_indent)
688
- result[cur] << l2.rstrip
689
- i += 1
690
- next
691
- end
692
-
693
- # Otherwise, this line does not belong to the current list item
694
- break
695
- end
696
-
697
- next
698
- end
699
-
700
- # Non-bullet, non-heading line: just advance
701
- i += 1
702
- end
703
- result
704
- end
705
-
706
- dest_items = parse_items.call(dest_unrel_body)
707
-
708
- # 3) Build a single canonical Unreleased section: heading + the six standard subheads in order
709
- new_unrel_block = []
710
- new_unrel_block << tpl_unrel_heading
711
- std_heads.each do |h|
712
- new_unrel_block << h
713
- if dest_items[h] && !dest_items[h].empty?
714
- new_unrel_block.concat(dest_items[h])
715
- end
716
- end
717
-
718
- # 4) Compose final content: template preface + new unreleased + rest of destination (after its unreleased)
719
- tail_after_unrel = []
720
- if dest_unrel_idx
721
- tail_after_unrel = dest_lines[(dest_end_idx + 1)..-1] || []
722
- end
723
-
724
- # Ensure exactly one blank line between the Unreleased chunk and the next version chunk
725
- # - Strip trailing blanks from the newly built Unreleased block
726
- while new_unrel_block.any? && new_unrel_block.last.to_s.strip == ""
727
- new_unrel_block.pop
728
- end
729
- # - Strip leading blanks from the tail
730
- while tail_after_unrel.any? && tail_after_unrel.first.to_s.strip == ""
731
- tail_after_unrel.shift
732
- end
733
- merged_lines = tpl_header_pre + new_unrel_block
734
- # Insert a single separator blank line if there is any tail content
735
- merged_lines << "" if tail_after_unrel.any?
736
- merged_lines.concat(tail_after_unrel)
737
-
738
- c = merged_lines.join("\n")
739
- end
740
-
741
- # Collapse repeated whitespace in release headers only
742
- lines = c.split("\n", -1)
743
- lines.map! do |ln|
744
- if ln =~ /^##\s+\[.*\]/
745
- ln.gsub(/[ \t]+/, " ")
746
- else
747
- ln
748
- end
749
- end
750
- c = lines.join("\n")
751
- rescue StandardError => e
752
- Kettle::Dev.debug_error(e, __method__)
753
- # Fallback: whitespace normalization
754
- lines = c.split("\n", -1)
755
- lines.map! { |ln| (ln =~ /^##\s+\[.*\]/) ? ln.gsub(/[ \t]+/, " ") : ln }
756
- c = lines.join("\n")
757
- end
758
- end
759
- # Normalize spacing around Markdown headings for broad renderer compatibility
760
- c = normalize_heading_spacing(c)
761
- c
762
- end
763
- else
764
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
765
- end
766
- end
767
-
768
- # Post-process README H1 preservation using snapshot (replace entire H1 line)
769
- begin
770
- if existing_readme_before
771
- readme_path = File.join(project_root, "README.md")
772
- if File.file?(readme_path)
773
- prev = existing_readme_before
774
- newc = File.read(readme_path)
775
- prev_h1 = prev.lines.find { |ln| ln =~ /^#\s+/ }
776
- lines = newc.split("\n", -1)
777
- cur_h1_idx = lines.index { |ln| ln =~ /^#\s+/ }
778
- if prev_h1 && cur_h1_idx
779
- # Replace the entire H1 line with the previous README's H1 exactly
780
- lines[cur_h1_idx] = prev_h1.chomp
781
- File.open(readme_path, "w") { |f| f.write(lines.join("\n")) }
782
- end
783
- end
784
- end
785
- rescue StandardError => e
786
- Kettle::Dev.debug_error(e, __method__)
787
- # ignore post-processing errors
788
- end
789
-
790
- # 7b) certs/pboling.pem
791
- begin
792
- cert_src = File.join(gem_checkout_root, "certs", "pboling.pem")
793
- cert_dest = File.join(project_root, "certs", "pboling.pem")
794
- if File.exist?(cert_src)
795
- helpers.copy_file_with_prompt(cert_src, cert_dest, allow_create: true, allow_replace: true)
796
- end
797
- rescue StandardError => e
798
- puts "WARNING: Skipped copying certs/pboling.pem due to #{e.class}: #{e.message}"
799
- end
800
-
801
- # After creating or replacing .envrc or .env.local.example, require review and exit unless allowed
802
- begin
803
- envrc_path = File.join(project_root, ".envrc")
804
- envlocal_example_path = File.join(project_root, ".env.local.example")
805
- changed_env_files = []
806
- changed_env_files << envrc_path if helpers.modified_by_template?(envrc_path)
807
- changed_env_files << envlocal_example_path if helpers.modified_by_template?(envlocal_example_path)
808
- if !changed_env_files.empty?
809
- if ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
810
- puts "Detected updates to #{changed_env_files.map { |p| File.basename(p) }.join(" and ")}. Proceeding because allowed=true."
811
- else
812
- puts
813
- puts "IMPORTANT: The following environment-related files were created/updated:"
814
- changed_env_files.each { |p| puts " - #{p}" }
815
- puts
816
- puts "Please review these files. If .envrc changed, run:"
817
- puts " direnv allow"
818
- puts
819
- puts "After that, re-run to resume:"
820
- puts " bundle exec rake kettle:dev:template allowed=true"
821
- puts " # or to run the full install afterwards:"
822
- puts " bundle exec rake kettle:dev:install allowed=true"
823
- task_abort("Aborting: review of environment files required before continuing.")
824
- end
825
- end
826
- rescue StandardError => e
827
- # Do not swallow intentional task aborts
828
- raise if e.is_a?(Kettle::Dev::Error)
829
-
830
- puts "WARNING: Could not determine env file changes: #{e.class}: #{e.message}"
831
- end
832
-
833
- # Handle .git-hooks files (see original rake task for details)
834
- source_hooks_dir = File.join(gem_checkout_root, ".git-hooks")
835
- if Dir.exist?(source_hooks_dir)
836
- # Honor ENV["only"]: skip entire .git-hooks handling unless patterns include .git-hooks
837
- begin
838
- only_raw = ENV["only"].to_s
839
- if !only_raw.empty?
840
- patterns = only_raw.split(",").map { |s| s.strip }.reject(&:empty?)
841
- if !patterns.empty?
842
- proj = helpers.project_root.to_s
843
- target_dir = File.join(proj, ".git-hooks")
844
- # Determine if any pattern would match either the directory itself (with /** semantics) or files within it
845
- matches = patterns.any? do |pat|
846
- if pat.end_with?("/**")
847
- base = pat[0..-4]
848
- base == ".git-hooks" || base == target_dir.sub(/^#{Regexp.escape(proj)}\/?/, "")
849
- else
850
- # Check for explicit .git-hooks or subpaths
851
- File.fnmatch?(pat, ".git-hooks", File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH) ||
852
- File.fnmatch?(pat, ".git-hooks/*", File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
853
- end
854
- end
855
- unless matches
856
- # No interest in .git-hooks => skip prompts and copies for hooks entirely
857
- # Note: we intentionally do not record template_results for hooks
858
- return
859
- end
860
- end
861
- end
862
- rescue StandardError => e
863
- Kettle::Dev.debug_error(e, __method__)
864
- # If filter parsing fails, proceed as before
865
- end
866
- # Prefer .example variant when present for .git-hooks
867
- goalie_src = helpers.prefer_example(File.join(source_hooks_dir, "commit-subjects-goalie.txt"))
868
- footer_src = helpers.prefer_example(File.join(source_hooks_dir, "footer-template.erb.txt"))
869
- hook_ruby_src = helpers.prefer_example(File.join(source_hooks_dir, "commit-msg"))
870
- hook_sh_src = helpers.prefer_example(File.join(source_hooks_dir, "prepare-commit-msg"))
871
-
872
- # First: templates (.txt) — ask local/global/skip
873
- if File.file?(goalie_src) && File.file?(footer_src)
874
- puts
875
- puts "Git hooks templates found:"
876
- puts " - #{goalie_src}"
877
- puts " - #{footer_src}"
878
- puts
879
- puts "About these files:"
880
- puts "- commit-subjects-goalie.txt:"
881
- puts " Lists commit subject prefixes to look for; if a commit subject starts with any listed prefix,"
882
- puts " kettle-commit-msg will append a footer to the commit message (when GIT_HOOK_FOOTER_APPEND=true)."
883
- puts " Defaults include release prep (🔖 Prepare release v) and checksum commits (🔒️ Checksums for v)."
884
- puts "- footer-template.erb.txt:"
885
- puts " ERB template rendered to produce the footer. You can customize its contents and variables."
886
- puts
887
- puts "Where would you like to install these two templates?"
888
- puts " [l] Local to this project (#{File.join(project_root, ".git-hooks")})"
889
- puts " [g] Global for this user (#{File.join(ENV["HOME"], ".git-hooks")})"
890
- puts " [s] Skip copying"
891
- # Allow non-interactive selection via environment
892
- # Precedence: CLI switch (hook_templates) > KETTLE_DEV_HOOK_TEMPLATES > prompt
893
- env_choice = ENV["hook_templates"]
894
- env_choice = ENV["KETTLE_DEV_HOOK_TEMPLATES"] if env_choice.nil? || env_choice.strip.empty?
895
- choice = env_choice&.strip
896
- unless choice && !choice.empty?
897
- print("Choose (l/g/s) [l]: ")
898
- choice = Kettle::Dev::InputAdapter.gets&.strip
899
- end
900
- choice = "l" if choice.nil? || choice.empty?
901
- dest_dir = case choice.downcase
902
- when "g", "global" then File.join(ENV["HOME"], ".git-hooks")
903
- when "s", "skip" then nil
904
- else File.join(project_root, ".git-hooks")
905
- end
906
-
907
- if dest_dir
908
- FileUtils.mkdir_p(dest_dir)
909
- [[goalie_src, "commit-subjects-goalie.txt"], [footer_src, "footer-template.erb.txt"]].each do |src, base|
910
- dest = File.join(dest_dir, base)
911
- # Allow create/replace prompts for these files (question applies to them)
912
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
913
- # Ensure readable (0644). These are data/templates, not executables.
914
- begin
915
- File.chmod(0o644, dest) if File.exist?(dest)
916
- rescue StandardError => e
917
- Kettle::Dev.debug_error(e, __method__)
918
- # ignore permission issues
919
- end
920
- end
921
- else
922
- puts "Skipping copy of .git-hooks templates."
923
- end
924
- end
925
-
926
- # Second: hook scripts — copy only to local project; prompt only on overwrite
927
- hook_dests = [File.join(project_root, ".git-hooks")]
928
- hook_pairs = [[hook_ruby_src, "commit-msg", 0o755], [hook_sh_src, "prepare-commit-msg", 0o755]]
929
- hook_pairs.each do |src, base, mode|
930
- next unless File.file?(src)
931
-
932
- hook_dests.each do |dstdir|
933
- begin
934
- FileUtils.mkdir_p(dstdir)
935
- dest = File.join(dstdir, base)
936
- # Create without prompt if missing; if exists, ask to replace
937
- if File.exist?(dest)
938
- if helpers.ask("Overwrite existing #{dest}?", true)
939
- content = File.read(src)
940
- helpers.write_file(dest, content)
941
- begin
942
- File.chmod(mode, dest)
943
- rescue StandardError => e
944
- Kettle::Dev.debug_error(e, __method__)
945
- # ignore permission issues
946
- end
947
- puts "Replaced #{dest}"
948
- else
949
- puts "Kept existing #{dest}"
950
- end
951
- else
952
- content = File.read(src)
953
- helpers.write_file(dest, content)
954
- begin
955
- File.chmod(mode, dest)
956
- rescue StandardError => e
957
- Kettle::Dev.debug_error(e, __method__)
958
- # ignore permission issues
959
- end
960
- puts "Installed #{dest}"
961
- end
962
- rescue StandardError => e
963
- puts "WARNING: Could not install hook #{base} to #{dstdir}: #{e.class}: #{e.message}"
964
- end
965
- end
966
- end
967
- end
968
-
969
- # Done
970
- nil
971
- end
972
- end
973
- end
974
- end
975
- end