kettle-dev 1.0.9 → 1.0.11

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 (68) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +2 -2
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.github/workflows/coverage.yml.example +127 -0
  6. data/.github/workflows/discord-notifier.yml +2 -1
  7. data/.github/workflows/truffle.yml +0 -8
  8. data/.junie/guidelines.md +4 -3
  9. data/.simplecov +5 -1
  10. data/Appraisals +5 -0
  11. data/Appraisals.example +102 -0
  12. data/CHANGELOG.md +80 -25
  13. data/CHANGELOG.md.example +4 -4
  14. data/CONTRIBUTING.md +43 -1
  15. data/Gemfile +3 -0
  16. data/README.md +65 -14
  17. data/README.md.example +515 -0
  18. data/{Rakefile → Rakefile.example} +17 -35
  19. data/exe/kettle-changelog +401 -0
  20. data/exe/kettle-commit-msg +11 -143
  21. data/exe/kettle-readme-backers +8 -352
  22. data/exe/kettle-release +7 -706
  23. data/gemfiles/modular/optional.gemfile +5 -0
  24. data/lib/kettle/dev/ci_helpers.rb +1 -0
  25. data/lib/kettle/dev/commit_msg.rb +39 -0
  26. data/lib/kettle/dev/exit_adapter.rb +36 -0
  27. data/lib/kettle/dev/git_adapter.rb +185 -0
  28. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  29. data/lib/kettle/dev/input_adapter.rb +40 -0
  30. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  31. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  32. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  33. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  34. data/lib/kettle/dev/rakelib/install.rake +1 -295
  35. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  36. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  37. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  38. data/lib/kettle/dev/rakelib/template.rake +3 -465
  39. data/lib/kettle/dev/readme_backers.rb +340 -0
  40. data/lib/kettle/dev/release_cli.rb +674 -0
  41. data/lib/kettle/dev/tasks/ci_task.rb +337 -0
  42. data/lib/kettle/dev/tasks/install_task.rb +516 -0
  43. data/lib/kettle/dev/tasks/template_task.rb +593 -0
  44. data/lib/kettle/dev/template_helpers.rb +65 -12
  45. data/lib/kettle/dev/version.rb +1 -1
  46. data/lib/kettle/dev/versioning.rb +68 -0
  47. data/lib/kettle/dev.rb +30 -1
  48. data/lib/kettle-dev.rb +2 -3
  49. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  50. data/sig/kettle/dev/commit_msg.rbs +8 -0
  51. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  52. data/sig/kettle/dev/git_adapter.rbs +15 -0
  53. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  54. data/sig/kettle/dev/input_adapter.rbs +8 -0
  55. data/sig/kettle/dev/readme_backers.rbs +20 -0
  56. data/sig/kettle/dev/release_cli.rbs +8 -0
  57. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  58. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  59. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  60. data/sig/kettle/dev/tasks.rbs +0 -0
  61. data/sig/kettle/dev/template_helpers.rbs +3 -1
  62. data/sig/kettle/dev/version.rbs +0 -0
  63. data/sig/kettle/emoji_regex.rbs +5 -0
  64. data/sig/kettle-dev.rbs +0 -0
  65. data.tar.gz.sig +0 -0
  66. metadata +59 -10
  67. metadata.gz.sig +0 -0
  68. data/.gitlab-ci.yml +0 -45
@@ -0,0 +1,593 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/exit_adapter"
4
+ require "kettle/dev/input_adapter"
5
+
6
+ module Kettle
7
+ module Dev
8
+ module Tasks
9
+ # Thin wrapper to expose the kettle:dev:template task logic as a callable API
10
+ # for testability. The rake task should only call this method.
11
+ module TemplateTask
12
+ module_function
13
+
14
+ # Abort wrapper that avoids terminating the entire process during specs
15
+ def task_abort(msg)
16
+ if defined?(RSpec)
17
+ raise Kettle::Dev::Error, msg
18
+ else
19
+ Kettle::Dev::ExitAdapter.abort(msg)
20
+ end
21
+ end
22
+
23
+ # Execute the template operation into the current project.
24
+ # All options/IO are controlled via TemplateHelpers and ENV.
25
+ def run
26
+ # Inline the former rake task body, but using helpers directly.
27
+ helpers = Kettle::Dev::TemplateHelpers
28
+
29
+ project_root = helpers.project_root
30
+ gem_checkout_root = helpers.gem_checkout_root
31
+
32
+ # Ensure git working tree is clean before making changes (when run standalone)
33
+ helpers.ensure_clean_git!(root: project_root, task_label: "kettle:dev:template")
34
+
35
+ meta = helpers.gemspec_metadata(project_root)
36
+ gem_name = meta[:gem_name]
37
+ min_ruby = meta[:min_ruby]
38
+ forge_org = meta[:forge_org] || meta[:gh_org]
39
+ funding_org = meta[:funding_org] || forge_org
40
+ entrypoint_require = meta[:entrypoint_require]
41
+ namespace = meta[:namespace]
42
+ namespace_shield = meta[:namespace_shield]
43
+ gem_shield = meta[:gem_shield]
44
+
45
+ # 1) .devcontainer directory
46
+ helpers.copy_dir_with_prompt(File.join(gem_checkout_root, ".devcontainer"), File.join(project_root, ".devcontainer"))
47
+
48
+ # 2) .github/**/*.yml with FUNDING.yml customizations
49
+ source_github_dir = File.join(gem_checkout_root, ".github")
50
+ if Dir.exist?(source_github_dir)
51
+ files = Dir.glob(File.join(source_github_dir, "**", "*.yml")) +
52
+ Dir.glob(File.join(source_github_dir, "**", "*.yml.example"))
53
+ files.uniq.each do |orig_src|
54
+ src = helpers.prefer_example(orig_src)
55
+ # Destination path should never include the .example suffix.
56
+ rel = orig_src.sub(/^#{Regexp.escape(gem_checkout_root)}\/?/, "").sub(/\.example\z/, "")
57
+ dest = File.join(project_root, rel)
58
+ if File.basename(rel) == "FUNDING.yml"
59
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
60
+ c = content.dup
61
+ c = c.gsub(/^open_collective:\s+.*$/i) { |line| funding_org ? "open_collective: #{funding_org}" : line }
62
+ if gem_name && !gem_name.empty?
63
+ c = c.gsub(/^tidelift:\s+.*$/i, "tidelift: rubygems/#{gem_name}")
64
+ end
65
+ # Also apply common replacements for org/gem/namespace/shields
66
+ helpers.apply_common_replacements(
67
+ c,
68
+ org: forge_org,
69
+ gem_name: gem_name,
70
+ namespace: namespace,
71
+ namespace_shield: namespace_shield,
72
+ gem_shield: gem_shield,
73
+ )
74
+ end
75
+ else
76
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
77
+ helpers.apply_common_replacements(
78
+ content,
79
+ org: forge_org,
80
+ gem_name: gem_name,
81
+ namespace: namespace,
82
+ namespace_shield: namespace_shield,
83
+ gem_shield: gem_shield,
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # 3) .qlty/qlty.toml
91
+ helpers.copy_file_with_prompt(
92
+ helpers.prefer_example(File.join(gem_checkout_root, ".qlty/qlty.toml")),
93
+ File.join(project_root, ".qlty/qlty.toml"),
94
+ allow_create: true,
95
+ allow_replace: true,
96
+ )
97
+
98
+ # 4) gemfiles/modular/*.gemfile (from gem's gemfiles/modular)
99
+ [%w[coverage.gemfile], %w[documentation.gemfile], %w[style.gemfile]].each do |base|
100
+ src = helpers.prefer_example(File.join(gem_checkout_root, "gemfiles/modular", base[0]))
101
+ dest = File.join(project_root, "gemfiles/modular", base[0])
102
+ if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
103
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
104
+ # Adjust rubocop-lts constraint based on min_ruby
105
+ version_map = [
106
+ [Gem::Version.new("1.8"), "~> 0.0"],
107
+ [Gem::Version.new("1.9"), "~> 2.0"],
108
+ [Gem::Version.new("2.0"), "~> 4.0"],
109
+ [Gem::Version.new("2.1"), "~> 6.0"],
110
+ [Gem::Version.new("2.2"), "~> 8.0"],
111
+ [Gem::Version.new("2.3"), "~> 10.0"],
112
+ [Gem::Version.new("2.4"), "~> 12.0"],
113
+ [Gem::Version.new("2.5"), "~> 14.0"],
114
+ [Gem::Version.new("2.6"), "~> 16.0"],
115
+ [Gem::Version.new("2.7"), "~> 18.0"],
116
+ [Gem::Version.new("3.0"), "~> 20.0"],
117
+ [Gem::Version.new("3.1"), "~> 22.0"],
118
+ [Gem::Version.new("3.2"), "~> 24.0"],
119
+ [Gem::Version.new("3.3"), "~> 26.0"],
120
+ [Gem::Version.new("3.4"), "~> 28.0"],
121
+ ]
122
+ new_constraint = nil
123
+ begin
124
+ if min_ruby && !min_ruby.empty?
125
+ v = Gem::Version.new(min_ruby.split(".")[0, 2].join("."))
126
+ version_map.reverse_each do |min, req|
127
+ if v >= min
128
+ new_constraint = req
129
+ break
130
+ end
131
+ end
132
+ end
133
+ rescue StandardError
134
+ # leave nil
135
+ end
136
+ if new_constraint
137
+ content.gsub(/^gem\s+"rubocop-lts",\s*"[^"]+".*$/) do |_line|
138
+ # Do not preserve whatever tail was there before
139
+ %(gem "rubocop-lts", "#{new_constraint}")
140
+ end
141
+ else
142
+ content
143
+ end
144
+ end
145
+ else
146
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
147
+ end
148
+ end
149
+
150
+ # 5) spec/spec_helper.rb (no create)
151
+ dest_spec_helper = File.join(project_root, "spec/spec_helper.rb")
152
+ if File.file?(dest_spec_helper)
153
+ old = File.read(dest_spec_helper)
154
+ if old.include?('require "kettle/dev"') || old.include?("require 'kettle/dev'")
155
+ replacement = %(require "#{entrypoint_require}")
156
+ new_content = old.gsub(/require\s+["']kettle\/dev["']/, replacement)
157
+ if new_content != old
158
+ if helpers.ask("Replace require \"kettle/dev\" in spec/spec_helper.rb with #{replacement}?", true)
159
+ helpers.write_file(dest_spec_helper, new_content)
160
+ puts "Updated require in spec/spec_helper.rb"
161
+ else
162
+ puts "Skipped modifying spec/spec_helper.rb"
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ # 6) .env.local special case: never read or touch .env.local from source; only copy .env.local.example to .env.local.example
169
+ begin
170
+ envlocal_src = File.join(gem_checkout_root, ".env.local.example")
171
+ envlocal_dest = File.join(project_root, ".env.local.example")
172
+ if File.exist?(envlocal_src)
173
+ helpers.copy_file_with_prompt(envlocal_src, envlocal_dest, allow_create: true, allow_replace: true)
174
+ end
175
+ rescue StandardError => e
176
+ puts "WARNING: Skipped .env.local example copy due to #{e.class}: #{e.message}"
177
+ end
178
+
179
+ # 7) Root and other files
180
+ files_to_copy = %w[
181
+ .envrc
182
+ .gitignore
183
+ .gitlab-ci.yml
184
+ .rspec
185
+ .rubocop.yml
186
+ .simplecov
187
+ .tool-versions
188
+ .yard_gfm_support.rb
189
+ .yardopts
190
+ .opencollective.yml
191
+ Appraisal.root.gemfile
192
+ Appraisals
193
+ CHANGELOG.md
194
+ CITATION.cff
195
+ CODE_OF_CONDUCT.md
196
+ CONTRIBUTING.md
197
+ Gemfile
198
+ Rakefile
199
+ README.md
200
+ RUBOCOP.md
201
+ SECURITY.md
202
+ .junie/guidelines.md
203
+ .junie/guidelines-rbs.md
204
+ ]
205
+
206
+ # Snapshot existing README content once (for H1 prefix preservation after write)
207
+ existing_readme_before = begin
208
+ path = File.join(project_root, "README.md")
209
+ File.file?(path) ? File.read(path) : nil
210
+ rescue StandardError
211
+ nil
212
+ end
213
+
214
+ files_to_copy.each do |rel|
215
+ src = helpers.prefer_example(File.join(gem_checkout_root, rel))
216
+ dest = File.join(project_root, rel)
217
+ next unless File.exist?(src)
218
+ if File.basename(rel) == "README.md"
219
+ # Precompute destination README H1 prefix (emoji(s) or first grapheme) before any overwrite occurs
220
+ prev_readme = File.exist?(dest) ? File.read(dest) : nil
221
+ begin
222
+ if prev_readme
223
+ first_h1_prev = prev_readme.lines.find { |ln| ln =~ /^#\s+/ }
224
+ if first_h1_prev
225
+ require "kettle/emoji_regex"
226
+ emoji_re = Kettle::EmojiRegex::REGEX
227
+ tail = first_h1_prev.sub(/^#\s+/, "")
228
+ # Extract consecutive leading emoji graphemes
229
+ out = +""
230
+ s = tail.dup
231
+ loop do
232
+ cluster = s[/\A\X/u]
233
+ break if cluster.nil? || cluster.empty?
234
+ if emoji_re.match?(cluster)
235
+ out << cluster
236
+ s = s[cluster.length..-1].to_s
237
+ else
238
+ break
239
+ end
240
+ end
241
+ if !out.empty?
242
+ out
243
+ else
244
+ # Fallback to first grapheme
245
+ tail[/\A\X/u]
246
+ end
247
+ end
248
+ end
249
+ rescue StandardError
250
+ # ignore, leave dest_preserve_prefix as nil
251
+ end
252
+
253
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
254
+ # 1) Do token replacements on the template content (org/gem/namespace/shields)
255
+ c = helpers.apply_common_replacements(
256
+ content,
257
+ org: forge_org,
258
+ gem_name: gem_name,
259
+ namespace: namespace,
260
+ namespace_shield: namespace_shield,
261
+ gem_shield: gem_shield,
262
+ )
263
+
264
+ # 2) Merge specific sections from destination README, if present
265
+ begin
266
+ dest_existing = prev_readme
267
+
268
+ # Parse Markdown headings while ignoring fenced code blocks (``` ... ```)
269
+ build_sections = lambda do |md|
270
+ return {lines: [], sections: [], line_count: 0} unless md
271
+ lines = md.split("\n", -1)
272
+ line_count = lines.length
273
+
274
+ sections = []
275
+ in_code = false
276
+ fence_re = /^\s*```/ # start or end of fenced block
277
+
278
+ lines.each_with_index do |ln, i|
279
+ if ln =~ fence_re
280
+ in_code = !in_code
281
+ next
282
+ end
283
+ next if in_code
284
+ if (m = ln.match(/^(#+)\s+.+/))
285
+ level = m[1].length
286
+ title = ln.sub(/^#+\s+/, "")
287
+ base = title.sub(/\A[^\p{Alnum}]+/u, "").strip.downcase
288
+ sections << {start: i, level: level, heading: ln, base: base}
289
+ end
290
+ end
291
+
292
+ # Compute stop indices based on next heading of same or higher level
293
+ sections.each_with_index do |sec, i|
294
+ j = i + 1
295
+ stop = line_count - 1
296
+ while j < sections.length
297
+ if sections[j][:level] <= sec[:level]
298
+ stop = sections[j][:start] - 1
299
+ break
300
+ end
301
+ j += 1
302
+ end
303
+ sec[:stop_to_next_any] = stop
304
+ body_lines_any = lines[(sec[:start] + 1)..stop] || []
305
+ sec[:body_to_next_any] = body_lines_any.join("\n")
306
+ end
307
+
308
+ {lines: lines, sections: sections, line_count: line_count}
309
+ end
310
+
311
+ # Helper: Compute the branch end (inclusive) for a section at index i
312
+ branch_end_index = lambda do |sections_arr, i, total_lines|
313
+ current = sections_arr[i]
314
+ j = i + 1
315
+ while j < sections_arr.length
316
+ return sections_arr[j][:start] - 1 if sections_arr[j][:level] <= current[:level]
317
+ j += 1
318
+ end
319
+ total_lines - 1
320
+ end
321
+
322
+ src_parsed = build_sections.call(c)
323
+ dest_parsed = build_sections.call(dest_existing)
324
+
325
+ # Build lookup for destination sections by base title, using full branch body (to next heading of same or higher level)
326
+ dest_lookup = {}
327
+ if dest_parsed && dest_parsed[:sections]
328
+ dest_parsed[:sections].each_with_index do |s, idx|
329
+ base = s[:base]
330
+ # Only set once (first occurrence wins)
331
+ next if dest_lookup.key?(base)
332
+ be = branch_end_index.call(dest_parsed[:sections], idx, dest_parsed[:line_count])
333
+ body_lines = dest_parsed[:lines][(s[:start] + 1)..be] || []
334
+ dest_lookup[base] = {body_branch: body_lines.join("\n"), level: s[:level]}
335
+ end
336
+ end
337
+
338
+ # Build targets to merge: existing curated list plus any NOTE sections at any level
339
+ note_bases = []
340
+ if src_parsed && src_parsed[:sections]
341
+ note_bases = src_parsed[:sections]
342
+ .select { |s| s[:heading] =~ /^#+\s+note:.*/i }
343
+ .map { |s| s[:base] }
344
+ end
345
+ targets = ["synopsis", "configuration", "basic usage"] + note_bases
346
+
347
+ # Replace matching sections in src using full branch ranges
348
+ if src_parsed && src_parsed[:sections] && !src_parsed[:sections].empty?
349
+ lines = src_parsed[:lines].dup
350
+ # Iterate in reverse to keep indices valid
351
+ src_parsed[:sections].reverse_each.with_index do |sec, rev_i|
352
+ next unless targets.include?(sec[:base])
353
+ # Determine branch range in src for this section
354
+ # rev_i is reverse index; compute forward index
355
+ i = src_parsed[:sections].length - 1 - rev_i
356
+ src_end = branch_end_index.call(src_parsed[:sections], i, src_parsed[:line_count])
357
+ dest_entry = dest_lookup[sec[:base]]
358
+ new_body = dest_entry ? dest_entry[:body_branch] : "\n\n"
359
+ new_block = [sec[:heading], new_body].join("\n")
360
+ range_start = sec[:start]
361
+ range_end = src_end
362
+ # Remove old range
363
+ lines.slice!(range_start..range_end)
364
+ # Insert new block (split preserves potential empty tail)
365
+ insert_lines = new_block.split("\n", -1)
366
+ lines.insert(range_start, *insert_lines)
367
+ end
368
+ c = lines.join("\n")
369
+ end
370
+
371
+ # 3) Preserve entire H1 line from destination README, if any
372
+ begin
373
+ if dest_existing
374
+ dest_h1 = dest_existing.lines.find { |ln| ln =~ /^#\s+/ }
375
+ if dest_h1
376
+ lines_new = c.split("\n", -1)
377
+ src_h1_idx = lines_new.index { |ln| ln =~ /^#\s+/ }
378
+ if src_h1_idx
379
+ # Replace the entire H1 line with the destination's H1 exactly
380
+ lines_new[src_h1_idx] = dest_h1.chomp
381
+ c = lines_new.join("\n")
382
+ end
383
+ end
384
+ end
385
+ rescue StandardError
386
+ # ignore H1 preservation errors
387
+ end
388
+ rescue StandardError
389
+ # Best effort; if anything fails, keep c as-is
390
+ end
391
+
392
+ c
393
+ end
394
+ elsif ["CHANGELOG.md", "CITATION.cff", "CONTRIBUTING.md", ".opencollective.yml", ".junie/guidelines.md"].include?(rel)
395
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
396
+ c = helpers.apply_common_replacements(
397
+ content,
398
+ org: ((File.basename(rel) == ".opencollective.yml") ? funding_org : forge_org),
399
+ gem_name: gem_name,
400
+ namespace: namespace,
401
+ namespace_shield: namespace_shield,
402
+ gem_shield: gem_shield,
403
+ )
404
+ # Retain whitespace everywhere, except collapse repeated whitespace in CHANGELOG release headers only
405
+ if File.basename(rel) == "CHANGELOG.md"
406
+ lines = c.split("\n", -1)
407
+ lines.map! do |ln|
408
+ if ln =~ /^##\s+\[.*\]/
409
+ ln.gsub(/[ \t]+/, " ")
410
+ else
411
+ ln
412
+ end
413
+ end
414
+ c = lines.join("\n")
415
+ end
416
+ c
417
+ end
418
+ else
419
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
420
+ end
421
+ end
422
+
423
+ # Post-process README H1 preservation using snapshot (replace entire H1 line)
424
+ begin
425
+ if existing_readme_before
426
+ readme_path = File.join(project_root, "README.md")
427
+ if File.file?(readme_path)
428
+ prev = existing_readme_before
429
+ newc = File.read(readme_path)
430
+ prev_h1 = prev.lines.find { |ln| ln =~ /^#\s+/ }
431
+ lines = newc.split("\n", -1)
432
+ cur_h1_idx = lines.index { |ln| ln =~ /^#\s+/ }
433
+ if prev_h1 && cur_h1_idx
434
+ # Replace the entire H1 line with the previous README's H1 exactly
435
+ lines[cur_h1_idx] = prev_h1.chomp
436
+ File.open(readme_path, "w") { |f| f.write(lines.join("\n")) }
437
+ end
438
+ end
439
+ end
440
+ rescue StandardError
441
+ # ignore post-processing errors
442
+ end
443
+
444
+ # 7b) certs/pboling.pem
445
+ begin
446
+ cert_src = File.join(gem_checkout_root, "certs", "pboling.pem")
447
+ cert_dest = File.join(project_root, "certs", "pboling.pem")
448
+ if File.exist?(cert_src)
449
+ helpers.copy_file_with_prompt(cert_src, cert_dest, allow_create: true, allow_replace: true)
450
+ end
451
+ rescue StandardError => e
452
+ puts "WARNING: Skipped copying certs/pboling.pem due to #{e.class}: #{e.message}"
453
+ end
454
+
455
+ # After creating or replacing .envrc or .env.local.example, require review and exit unless allowed
456
+ begin
457
+ envrc_path = File.join(project_root, ".envrc")
458
+ envlocal_example_path = File.join(project_root, ".env.local.example")
459
+ changed_env_files = []
460
+ changed_env_files << envrc_path if helpers.modified_by_template?(envrc_path)
461
+ changed_env_files << envlocal_example_path if helpers.modified_by_template?(envlocal_example_path)
462
+ if !changed_env_files.empty?
463
+ if ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
464
+ puts "Detected updates to #{changed_env_files.map { |p| File.basename(p) }.join(" and ")}. Proceeding because allowed=true."
465
+ else
466
+ puts
467
+ puts "IMPORTANT: The following environment-related files were created/updated:"
468
+ changed_env_files.each { |p| puts " - #{p}" }
469
+ puts
470
+ puts "Please review these files. If .envrc changed, run:"
471
+ puts " direnv allow"
472
+ puts
473
+ puts "After that, re-run to resume:"
474
+ puts " bundle exec rake kettle:dev:template allowed=true"
475
+ puts " # or to run the full install afterwards:"
476
+ puts " bundle exec rake kettle:dev:install allowed=true"
477
+ task_abort("Aborting: review of environment files required before continuing.")
478
+ end
479
+ end
480
+ rescue StandardError => e
481
+ # Do not swallow intentional task aborts
482
+ raise if e.is_a?(Kettle::Dev::Error)
483
+ puts "WARNING: Could not determine env file changes: #{e.class}: #{e.message}"
484
+ end
485
+
486
+ # Handle .git-hooks files (see original rake task for details)
487
+ source_hooks_dir = File.join(gem_checkout_root, ".git-hooks")
488
+ if Dir.exist?(source_hooks_dir)
489
+ goalie_src = File.join(source_hooks_dir, "commit-subjects-goalie.txt")
490
+ footer_src = File.join(source_hooks_dir, "footer-template.erb.txt")
491
+ hook_ruby_src = File.join(source_hooks_dir, "commit-msg")
492
+ hook_sh_src = File.join(source_hooks_dir, "prepare-commit-msg")
493
+
494
+ # First: templates (.txt) — ask local/global/skip
495
+ if File.file?(goalie_src) && File.file?(footer_src)
496
+ puts
497
+ puts "Git hooks templates found:"
498
+ puts " - #{goalie_src}"
499
+ puts " - #{footer_src}"
500
+ puts
501
+ puts "About these files:"
502
+ puts "- commit-subjects-goalie.txt:"
503
+ puts " Lists commit subject prefixes to look for; if a commit subject starts with any listed prefix,"
504
+ puts " kettle-commit-msg will append a footer to the commit message (when GIT_HOOK_FOOTER_APPEND=true)."
505
+ puts " Defaults include release prep (🔖 Prepare release v) and checksum commits (🔒️ Checksums for v)."
506
+ puts "- footer-template.erb.txt:"
507
+ puts " ERB template rendered to produce the footer. You can customize its contents and variables."
508
+ puts
509
+ puts "Where would you like to install these two templates?"
510
+ puts " [l] Local to this project (#{File.join(project_root, ".git-hooks")})"
511
+ puts " [g] Global for this user (#{File.join(ENV["HOME"], ".git-hooks")})"
512
+ puts " [s] Skip copying"
513
+ # Allow non-interactive selection via environment
514
+ # Precedence: CLI switch (hook_templates) > KETTLE_DEV_HOOK_TEMPLATES > prompt
515
+ env_choice = ENV["hook_templates"]
516
+ env_choice = ENV["KETTLE_DEV_HOOK_TEMPLATES"] if env_choice.nil? || env_choice.strip.empty?
517
+ choice = env_choice&.strip
518
+ unless choice && !choice.empty?
519
+ print("Choose (l/g/s) [l]: ")
520
+ choice = Kettle::Dev::InputAdapter.gets&.strip
521
+ end
522
+ choice = "l" if choice.nil? || choice.empty?
523
+ dest_dir = case choice.downcase
524
+ when "g", "global" then File.join(ENV["HOME"], ".git-hooks")
525
+ when "s", "skip" then nil
526
+ else File.join(project_root, ".git-hooks")
527
+ end
528
+
529
+ if dest_dir
530
+ FileUtils.mkdir_p(dest_dir)
531
+ [[goalie_src, "commit-subjects-goalie.txt"], [footer_src, "footer-template.erb.txt"]].each do |src, base|
532
+ dest = File.join(dest_dir, base)
533
+ # Allow create/replace prompts for these files (question applies to them)
534
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
535
+ # Ensure readable (0644). These are data/templates, not executables.
536
+ begin
537
+ File.chmod(0o644, dest) if File.exist?(dest)
538
+ rescue StandardError
539
+ # ignore permission issues
540
+ end
541
+ end
542
+ else
543
+ puts "Skipping copy of .git-hooks templates."
544
+ end
545
+ end
546
+
547
+ # Second: hook scripts — copy only to local project; prompt only on overwrite
548
+ hook_dests = [File.join(project_root, ".git-hooks")]
549
+ hook_pairs = [[hook_ruby_src, "commit-msg", 0o755], [hook_sh_src, "prepare-commit-msg", 0o755]]
550
+ hook_pairs.each do |src, base, mode|
551
+ next unless File.file?(src)
552
+ hook_dests.each do |dstdir|
553
+ begin
554
+ FileUtils.mkdir_p(dstdir)
555
+ dest = File.join(dstdir, base)
556
+ # Create without prompt if missing; if exists, ask to replace
557
+ if File.exist?(dest)
558
+ if helpers.ask("Overwrite existing #{dest}?", true)
559
+ content = File.read(src)
560
+ helpers.write_file(dest, content)
561
+ begin
562
+ File.chmod(mode, dest)
563
+ rescue StandardError
564
+ # ignore permission issues
565
+ end
566
+ puts "Replaced #{dest}"
567
+ else
568
+ puts "Kept existing #{dest}"
569
+ end
570
+ else
571
+ content = File.read(src)
572
+ helpers.write_file(dest, content)
573
+ begin
574
+ File.chmod(mode, dest)
575
+ rescue StandardError
576
+ # ignore permission issues
577
+ end
578
+ puts "Installed #{dest}"
579
+ end
580
+ rescue StandardError => e
581
+ puts "WARNING: Could not install hook #{base} to #{dstdir}: #{e.class}: #{e.message}"
582
+ end
583
+ end
584
+ end
585
+ end
586
+
587
+ # Done
588
+ nil
589
+ end
590
+ end
591
+ end
592
+ end
593
+ end