kettle-dev 1.0.9 → 1.0.10

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 (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.junie/guidelines.md +4 -3
  6. data/.simplecov +5 -1
  7. data/Appraisals +3 -0
  8. data/CHANGELOG.md +22 -1
  9. data/CONTRIBUTING.md +6 -0
  10. data/README.md +18 -5
  11. data/Rakefile +7 -11
  12. data/exe/kettle-commit-msg +9 -143
  13. data/exe/kettle-readme-backers +7 -353
  14. data/exe/kettle-release +8 -702
  15. data/lib/kettle/dev/ci_helpers.rb +1 -0
  16. data/lib/kettle/dev/commit_msg.rb +39 -0
  17. data/lib/kettle/dev/exit_adapter.rb +36 -0
  18. data/lib/kettle/dev/git_adapter.rb +120 -0
  19. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  20. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  21. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  22. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  23. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  24. data/lib/kettle/dev/rakelib/install.rake +1 -295
  25. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  26. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  27. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  28. data/lib/kettle/dev/rakelib/template.rake +3 -465
  29. data/lib/kettle/dev/readme_backers.rb +340 -0
  30. data/lib/kettle/dev/release_cli.rb +672 -0
  31. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  32. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  33. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  34. data/lib/kettle/dev/template_helpers.rb +4 -4
  35. data/lib/kettle/dev/version.rb +1 -1
  36. data/lib/kettle/dev.rb +30 -1
  37. data/lib/kettle-dev.rb +2 -3
  38. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  39. data/sig/kettle/dev/commit_msg.rbs +8 -0
  40. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  41. data/sig/kettle/dev/git_adapter.rbs +15 -0
  42. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  43. data/sig/kettle/dev/readme_backers.rbs +20 -0
  44. data/sig/kettle/dev/release_cli.rbs +8 -0
  45. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  46. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  47. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  48. data/sig/kettle/dev/tasks.rbs +0 -0
  49. data/sig/kettle/dev/version.rbs +0 -0
  50. data/sig/kettle/emoji_regex.rbs +5 -0
  51. data/sig/kettle-dev.rbs +0 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +55 -5
  54. metadata.gz.sig +0 -0
@@ -0,0 +1,491 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/exit_adapter"
4
+
5
+ module Kettle
6
+ module Dev
7
+ module Tasks
8
+ # Thin wrapper to expose the kettle:dev:template task logic as a callable API
9
+ # for testability. The rake task should only call this method.
10
+ module TemplateTask
11
+ module_function
12
+
13
+ # Abort wrapper that avoids terminating the entire process during specs
14
+ def task_abort(msg)
15
+ if defined?(RSpec)
16
+ raise Kettle::Dev::Error, msg
17
+ else
18
+ Kettle::Dev::ExitAdapter.abort(msg)
19
+ end
20
+ end
21
+
22
+ # Execute the template operation into the current project.
23
+ # All options/IO are controlled via TemplateHelpers and ENV.
24
+ def run
25
+ # Inline the former rake task body, but using helpers directly.
26
+ helpers = Kettle::Dev::TemplateHelpers
27
+
28
+ project_root = helpers.project_root
29
+ gem_checkout_root = helpers.gem_checkout_root
30
+
31
+ # Ensure git working tree is clean before making changes (when run standalone)
32
+ helpers.ensure_clean_git!(root: project_root, task_label: "kettle:dev:template")
33
+
34
+ meta = helpers.gemspec_metadata(project_root)
35
+ gem_name = meta[:gem_name]
36
+ min_ruby = meta[:min_ruby]
37
+ gh_org = meta[:gh_org]
38
+ entrypoint_require = meta[:entrypoint_require]
39
+ namespace = meta[:namespace]
40
+ namespace_shield = meta[:namespace_shield]
41
+ gem_shield = meta[:gem_shield]
42
+
43
+ # 1) .devcontainer directory
44
+ helpers.copy_dir_with_prompt(File.join(gem_checkout_root, ".devcontainer"), File.join(project_root, ".devcontainer"))
45
+
46
+ # 2) .github/**/*.yml with FUNDING.yml customizations
47
+ source_github_dir = File.join(gem_checkout_root, ".github")
48
+ if Dir.exist?(source_github_dir)
49
+ files = Dir.glob(File.join(source_github_dir, "**", "*.yml")) +
50
+ Dir.glob(File.join(source_github_dir, "**", "*.yml.example"))
51
+ files.uniq.each do |orig_src|
52
+ src = helpers.prefer_example(orig_src)
53
+ # Destination path should never include the .example suffix.
54
+ rel = orig_src.sub(/^#{Regexp.escape(gem_checkout_root)}\/?/, "").sub(/\.example\z/, "")
55
+ dest = File.join(project_root, rel)
56
+ if File.basename(rel) == "FUNDING.yml"
57
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
58
+ c = content.dup
59
+ c = c.gsub(/^open_collective:\s+.*$/i) { |line| gh_org ? "open_collective: #{gh_org}" : line }
60
+ if gem_name && !gem_name.empty?
61
+ c = c.gsub(/^tidelift:\s+.*$/i, "tidelift: rubygems/#{gem_name}")
62
+ end
63
+ # Also apply common replacements for org/gem/namespace/shields
64
+ helpers.apply_common_replacements(
65
+ c,
66
+ gh_org: gh_org,
67
+ gem_name: gem_name,
68
+ namespace: namespace,
69
+ namespace_shield: namespace_shield,
70
+ gem_shield: gem_shield,
71
+ )
72
+ end
73
+ else
74
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
75
+ helpers.apply_common_replacements(
76
+ content,
77
+ gh_org: gh_org,
78
+ gem_name: gem_name,
79
+ namespace: namespace,
80
+ namespace_shield: namespace_shield,
81
+ gem_shield: gem_shield,
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # 3) .qlty/qlty.toml
89
+ helpers.copy_file_with_prompt(
90
+ helpers.prefer_example(File.join(gem_checkout_root, ".qlty/qlty.toml")),
91
+ File.join(project_root, ".qlty/qlty.toml"),
92
+ allow_create: true,
93
+ allow_replace: true,
94
+ )
95
+
96
+ # 4) gemfiles/modular/*.gemfile (from gem's gemfiles/modular)
97
+ [%w[coverage.gemfile], %w[documentation.gemfile], %w[style.gemfile]].each do |base|
98
+ src = helpers.prefer_example(File.join(gem_checkout_root, "gemfiles/modular", base[0]))
99
+ dest = File.join(project_root, "gemfiles/modular", base[0])
100
+ if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
101
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
102
+ # Adjust rubocop-lts constraint based on min_ruby
103
+ version_map = [
104
+ [Gem::Version.new("1.8"), "~> 0.0"],
105
+ [Gem::Version.new("1.9"), "~> 2.0"],
106
+ [Gem::Version.new("2.0"), "~> 4.0"],
107
+ [Gem::Version.new("2.1"), "~> 6.0"],
108
+ [Gem::Version.new("2.2"), "~> 8.0"],
109
+ [Gem::Version.new("2.3"), "~> 10.0"],
110
+ [Gem::Version.new("2.4"), "~> 12.0"],
111
+ [Gem::Version.new("2.5"), "~> 14.0"],
112
+ [Gem::Version.new("2.6"), "~> 16.0"],
113
+ [Gem::Version.new("2.7"), "~> 18.0"],
114
+ [Gem::Version.new("3.0"), "~> 20.0"],
115
+ [Gem::Version.new("3.1"), "~> 22.0"],
116
+ [Gem::Version.new("3.2"), "~> 24.0"],
117
+ [Gem::Version.new("3.3"), "~> 26.0"],
118
+ [Gem::Version.new("3.4"), "~> 28.0"],
119
+ ]
120
+ new_constraint = nil
121
+ begin
122
+ if min_ruby && !min_ruby.empty?
123
+ v = Gem::Version.new(min_ruby.split(".")[0, 2].join("."))
124
+ version_map.reverse_each do |min, req|
125
+ if v >= min
126
+ new_constraint = req
127
+ break
128
+ end
129
+ end
130
+ end
131
+ rescue StandardError
132
+ # leave nil
133
+ end
134
+ if new_constraint
135
+ content.gsub(/^gem\s+"rubocop-lts",\s*"[^"]+".*$/) do |_line|
136
+ # Do not preserve whatever tail was there before
137
+ %(gem "rubocop-lts", "#{new_constraint}")
138
+ end
139
+ else
140
+ content
141
+ end
142
+ end
143
+ else
144
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
145
+ end
146
+ end
147
+
148
+ # 5) spec/spec_helper.rb (no create)
149
+ dest_spec_helper = File.join(project_root, "spec/spec_helper.rb")
150
+ if File.file?(dest_spec_helper)
151
+ old = File.read(dest_spec_helper)
152
+ if old.include?('require "kettle/dev"') || old.include?("require 'kettle/dev'")
153
+ replacement = %(require "#{entrypoint_require}")
154
+ new_content = old.gsub(/require\s+["']kettle\/dev["']/, replacement)
155
+ if new_content != old
156
+ if helpers.ask("Replace require \"kettle/dev\" in spec/spec_helper.rb with #{replacement}?", true)
157
+ helpers.write_file(dest_spec_helper, new_content)
158
+ puts "Updated require in spec/spec_helper.rb"
159
+ else
160
+ puts "Skipped modifying spec/spec_helper.rb"
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ # 6) .env.local special case: never overwrite project .env.local; copy template as .env.local.example
167
+ begin
168
+ envlocal_src = helpers.prefer_example(File.join(gem_checkout_root, ".env.local"))
169
+ envlocal_dest = File.join(project_root, ".env.local.example")
170
+ if File.exist?(envlocal_src)
171
+ helpers.copy_file_with_prompt(envlocal_src, envlocal_dest, allow_create: true, allow_replace: true)
172
+ end
173
+ rescue StandardError => e
174
+ puts "WARNING: Skipped .env.local example copy due to #{e.class}: #{e.message}"
175
+ end
176
+
177
+ # 7) Root and other files
178
+ files_to_copy = %w[
179
+ .envrc
180
+ .gitignore
181
+ .gitlab-ci.yml
182
+ .rspec
183
+ .rubocop.yml
184
+ .simplecov
185
+ .tool-versions
186
+ .yard_gfm_support.rb
187
+ .yardopts
188
+ .opencollective.yml
189
+ Appraisal.root.gemfile
190
+ Appraisals
191
+ CHANGELOG.md
192
+ CITATION.cff
193
+ CODE_OF_CONDUCT.md
194
+ CONTRIBUTING.md
195
+ Gemfile
196
+ Rakefile
197
+ README.md
198
+ RUBOCOP.md
199
+ SECURITY.md
200
+ .junie/guidelines.md
201
+ .junie/guidelines-rbs.md
202
+ ]
203
+
204
+ files_to_copy.each do |rel|
205
+ src = helpers.prefer_example(File.join(gem_checkout_root, rel))
206
+ dest = File.join(project_root, rel)
207
+ next unless File.exist?(src)
208
+ if File.basename(rel) == "README.md"
209
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
210
+ # 1) Do token replacements on the template content (org/gem/namespace/shields)
211
+ c = helpers.apply_common_replacements(
212
+ content,
213
+ gh_org: gh_org,
214
+ gem_name: gem_name,
215
+ namespace: namespace,
216
+ namespace_shield: namespace_shield,
217
+ gem_shield: gem_shield,
218
+ )
219
+
220
+ # 2) Merge specific sections from destination README, if present
221
+ begin
222
+ dest_existing = File.exist?(dest) ? File.read(dest) : nil
223
+
224
+ # Helper to parse markdown sections at any heading level (#, ##, ###, ...)
225
+ parse_sections = lambda do |md|
226
+ sections = []
227
+ return sections unless md
228
+ lines = md.split("\n", -1) # keep trailing empty lines
229
+ indices = []
230
+ lines.each_with_index do |ln, i|
231
+ indices << i if ln =~ /^#+\s+.+/
232
+ end
233
+ indices << lines.length
234
+ indices.each_cons(2) do |start_i, nxt|
235
+ heading = lines[start_i]
236
+ body_lines = lines[(start_i + 1)...nxt] || []
237
+ title = heading.sub(/^#+\s+/, "")
238
+ # Normalize by removing leading emoji/non-alnum and extra spaces
239
+ base = title.sub(/\A[^\p{Alnum}]+/u, "").strip.downcase
240
+ sections << {start: start_i, stop: nxt - 1, heading: heading, body: body_lines.join("\n"), base: base}
241
+ end
242
+ {lines: lines, sections: sections}
243
+ end
244
+
245
+ # Parse src (c) and dest
246
+ src_parsed = parse_sections.call(c)
247
+ dest_parsed = parse_sections.call(dest_existing)
248
+
249
+ # Build lookup for destination sections by base title
250
+ dest_lookup = {}
251
+ if dest_parsed && dest_parsed[:sections]
252
+ dest_parsed[:sections].each do |s|
253
+ dest_lookup[s[:base]] = s[:body]
254
+ end
255
+ end
256
+
257
+ # Build targets to merge: existing curated list plus any NOTE sections at any level
258
+ note_bases = []
259
+ if src_parsed && src_parsed[:sections]
260
+ note_bases = src_parsed[:sections]
261
+ .select { |s| s[:heading] =~ /^#+\s+note:.*/i }
262
+ .map { |s| s[:base] }
263
+ end
264
+ targets = ["synopsis", "configuration", "basic usage"] + note_bases
265
+
266
+ # Replace matching sections in src
267
+ if src_parsed && src_parsed[:sections] && !src_parsed[:sections].empty?
268
+ lines = src_parsed[:lines].dup
269
+ # Iterate over src sections; when base is in targets, rewrite its body
270
+ src_parsed[:sections].reverse_each do |sec|
271
+ next unless targets.include?(sec[:base])
272
+ new_body = dest_lookup.fetch(sec[:base], "\n\n")
273
+ new_block = [sec[:heading], new_body].join("\n")
274
+ # Replace the range from start+0 to stop with new_block lines
275
+ range_start = sec[:start]
276
+ range_end = sec[:stop]
277
+ # Remove old range
278
+ lines.slice!(range_start..range_end)
279
+ # Insert new block (split preserves potential empty tail)
280
+ insert_lines = new_block.split("\n", -1)
281
+ lines.insert(range_start, *insert_lines)
282
+ end
283
+ c = lines.join("\n")
284
+ end
285
+
286
+ # 3) Preserve first H1 emojis from destination README, if any
287
+ begin
288
+ emoji_re = Kettle::EmojiRegex::REGEX
289
+
290
+ dest_emojis = nil
291
+ if dest_existing
292
+ first_h1_dest = dest_existing.lines.find { |ln| ln =~ /^#\s+/ }
293
+ if first_h1_dest
294
+ after = first_h1_dest.sub(/^#\s+/, "")
295
+ emojis = +""
296
+ while after =~ /\A#{emoji_re.source}/u
297
+ # Capture the entire grapheme cluster for the emoji (handles VS16/ZWJ sequences)
298
+ cluster = after[/\A\X/u]
299
+ emojis << cluster
300
+ after = after[cluster.length..-1].to_s
301
+ end
302
+ dest_emojis = emojis unless emojis.empty?
303
+ end
304
+ end
305
+
306
+ if dest_emojis && !dest_emojis.empty?
307
+ lines_new = c.split("\n", -1)
308
+ idx = lines_new.index { |ln| ln =~ /^#\s+/ }
309
+ if idx
310
+ rest = lines_new[idx].sub(/^#\s+/, "")
311
+ # Remove any leading emojis from the H1 by peeling full grapheme clusters
312
+ rest_wo_emoji = begin
313
+ tmp = rest.dup
314
+ while tmp =~ /\A#{emoji_re.source}/u
315
+ cluster = tmp[/\A\X/u]
316
+ tmp = tmp[cluster.length..-1].to_s
317
+ end
318
+ tmp.sub(/\A\s+/, "")
319
+ end
320
+ lines_new[idx] = ["#", dest_emojis, rest_wo_emoji].join(" ").gsub(/\s+/, " ").sub(/^#\s+/, "# ")
321
+ c = lines_new.join("\n")
322
+ end
323
+ end
324
+ rescue StandardError
325
+ # ignore emoji preservation errors
326
+ end
327
+ rescue StandardError
328
+ # Best effort; if anything fails, keep c as-is
329
+ end
330
+
331
+ c
332
+ end
333
+ elsif ["CHANGELOG.md", "CITATION.cff", "CONTRIBUTING.md", ".opencollective.yml", ".junie/guidelines.md"].include?(rel)
334
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
335
+ helpers.apply_common_replacements(
336
+ content,
337
+ gh_org: gh_org,
338
+ gem_name: gem_name,
339
+ namespace: namespace,
340
+ namespace_shield: namespace_shield,
341
+ gem_shield: gem_shield,
342
+ )
343
+ end
344
+ else
345
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
346
+ end
347
+ end
348
+
349
+ # 7b) certs/pboling.pem
350
+ begin
351
+ cert_src = File.join(gem_checkout_root, "certs", "pboling.pem")
352
+ cert_dest = File.join(project_root, "certs", "pboling.pem")
353
+ if File.exist?(cert_src)
354
+ helpers.copy_file_with_prompt(cert_src, cert_dest, allow_create: true, allow_replace: true)
355
+ end
356
+ rescue StandardError => e
357
+ puts "WARNING: Skipped copying certs/pboling.pem due to #{e.class}: #{e.message}"
358
+ end
359
+
360
+ # After creating or replacing .envrc or .env.local.example, require review and exit unless allowed
361
+ begin
362
+ envrc_path = File.join(project_root, ".envrc")
363
+ envlocal_example_path = File.join(project_root, ".env.local.example")
364
+ changed_env_files = []
365
+ changed_env_files << envrc_path if helpers.modified_by_template?(envrc_path)
366
+ changed_env_files << envlocal_example_path if helpers.modified_by_template?(envlocal_example_path)
367
+ if !changed_env_files.empty?
368
+ if ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
369
+ puts "Detected updates to #{changed_env_files.map { |p| File.basename(p) }.join(" and ")}. Proceeding because allowed=true."
370
+ else
371
+ puts
372
+ puts "IMPORTANT: The following environment-related files were created/updated:"
373
+ changed_env_files.each { |p| puts " - #{p}" }
374
+ puts
375
+ puts "Please review these files. If .envrc changed, run:"
376
+ puts " direnv allow"
377
+ puts
378
+ puts "After that, re-run to resume:"
379
+ puts " bundle exec rake kettle:dev:template allowed=true"
380
+ puts " # or to run the full install afterwards:"
381
+ puts " bundle exec rake kettle:dev:install allowed=true"
382
+ task_abort("Aborting: review of environment files required before continuing.")
383
+ end
384
+ end
385
+ rescue StandardError => e
386
+ # Do not swallow intentional task aborts
387
+ raise if e.is_a?(Kettle::Dev::Error)
388
+ puts "WARNING: Could not determine env file changes: #{e.class}: #{e.message}"
389
+ end
390
+
391
+ # Handle .git-hooks files (see original rake task for details)
392
+ source_hooks_dir = File.join(gem_checkout_root, ".git-hooks")
393
+ if Dir.exist?(source_hooks_dir)
394
+ goalie_src = File.join(source_hooks_dir, "commit-subjects-goalie.txt")
395
+ footer_src = File.join(source_hooks_dir, "footer-template.erb.txt")
396
+ hook_ruby_src = File.join(source_hooks_dir, "commit-msg")
397
+ hook_sh_src = File.join(source_hooks_dir, "prepare-commit-msg")
398
+
399
+ # First: templates (.txt) — ask local/global/skip
400
+ if File.file?(goalie_src) && File.file?(footer_src)
401
+ puts
402
+ puts "Git hooks templates found:"
403
+ puts " - #{goalie_src}"
404
+ puts " - #{footer_src}"
405
+ puts
406
+ puts "About these files:"
407
+ puts "- commit-subjects-goalie.txt:"
408
+ puts " Lists commit subject prefixes to look for; if a commit subject starts with any listed prefix,"
409
+ puts " kettle-commit-msg will append a footer to the commit message (when GIT_HOOK_FOOTER_APPEND=true)."
410
+ puts " Defaults include release prep (🔖 Prepare release v) and checksum commits (🔒️ Checksums for v)."
411
+ puts "- footer-template.erb.txt:"
412
+ puts " ERB template rendered to produce the footer. You can customize its contents and variables."
413
+ puts
414
+ puts "Where would you like to install these two templates?"
415
+ puts " [l] Local to this project (#{File.join(project_root, ".git-hooks")})"
416
+ puts " [g] Global for this user (#{File.join(ENV["HOME"], ".git-hooks")})"
417
+ puts " [s] Skip copying"
418
+ print("Choose (l/g/s) [l]: ")
419
+ choice = $stdin.gets&.strip
420
+ choice = "l" if choice.nil? || choice.empty?
421
+ dest_dir = case choice.downcase
422
+ when "g", "global" then File.join(ENV["HOME"], ".git-hooks")
423
+ when "s", "skip" then nil
424
+ else File.join(project_root, ".git-hooks")
425
+ end
426
+
427
+ if dest_dir
428
+ FileUtils.mkdir_p(dest_dir)
429
+ [[goalie_src, "commit-subjects-goalie.txt"], [footer_src, "footer-template.erb.txt"]].each do |src, base|
430
+ dest = File.join(dest_dir, base)
431
+ # Allow create/replace prompts for these files (question applies to them)
432
+ helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
433
+ # Ensure readable (0644). These are data/templates, not executables.
434
+ begin
435
+ File.chmod(0o644, dest) if File.exist?(dest)
436
+ rescue StandardError
437
+ # ignore permission issues
438
+ end
439
+ end
440
+ else
441
+ puts "Skipping copy of .git-hooks templates."
442
+ end
443
+ end
444
+
445
+ # Second: hook scripts — copy only to local project; prompt only on overwrite
446
+ hook_dests = [File.join(project_root, ".git-hooks")]
447
+ hook_pairs = [[hook_ruby_src, "commit-msg", 0o755], [hook_sh_src, "prepare-commit-msg", 0o755]]
448
+ hook_pairs.each do |src, base, mode|
449
+ next unless File.file?(src)
450
+ hook_dests.each do |dstdir|
451
+ begin
452
+ FileUtils.mkdir_p(dstdir)
453
+ dest = File.join(dstdir, base)
454
+ # Create without prompt if missing; if exists, ask to replace
455
+ if File.exist?(dest)
456
+ if helpers.ask("Overwrite existing #{dest}?", true)
457
+ content = File.read(src)
458
+ helpers.write_file(dest, content)
459
+ begin
460
+ File.chmod(mode, dest)
461
+ rescue StandardError
462
+ # ignore permission issues
463
+ end
464
+ puts "Replaced #{dest}"
465
+ else
466
+ puts "Kept existing #{dest}"
467
+ end
468
+ else
469
+ content = File.read(src)
470
+ helpers.write_file(dest, content)
471
+ begin
472
+ File.chmod(mode, dest)
473
+ rescue StandardError
474
+ # ignore permission issues
475
+ end
476
+ puts "Installed #{dest}"
477
+ end
478
+ rescue StandardError => e
479
+ puts "WARNING: Could not install hook #{base} to #{dstdir}: #{e.class}: #{e.message}"
480
+ end
481
+ end
482
+ end
483
+ end
484
+
485
+ # Done
486
+ nil
487
+ end
488
+ end
489
+ end
490
+ end
491
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # :nocov:
3
+ # External stdlibs
4
4
  require "find"
5
- require_relative "ci_helpers"
6
5
 
7
6
  module Kettle
8
7
  module Dev
@@ -253,7 +252,9 @@ module Kettle
253
252
  gh_repo = gh_match && gh_match[2]&.sub(/\.git\z/, "")
254
253
  if gh_org.nil?
255
254
  begin
256
- origin_url = IO.popen(["git", "-C", root.to_s, "remote", "get-url", "origin"], &:read).to_s.strip
255
+ origin_out = IO.popen(["git", "-C", root.to_s, "remote", "get-url", "origin"], &:read)
256
+ origin_out = origin_out.read if origin_out.respond_to?(:read)
257
+ origin_url = origin_out.to_s.strip
257
258
  if (m = origin_url.match(%r{github\.com[/:]([^/]+)/([^/]+)}i))
258
259
  gh_org = m[1]
259
260
  gh_repo = m[2]&.sub(/\.git\z/, "")
@@ -287,4 +288,3 @@ module Kettle
287
288
  end
288
289
  end
289
290
  end
290
- # :nocov:
@@ -6,7 +6,7 @@ module Kettle
6
6
  module Version
7
7
  # The gem version.
8
8
  # @return [String]
9
- VERSION = "1.0.9"
9
+ VERSION = "1.0.10"
10
10
  end
11
11
  end
12
12
  end
data/lib/kettle/dev.rb CHANGED
@@ -6,7 +6,6 @@
6
6
  require "require_bench" if ENV.fetch("REQUIRE_BENCH", "false").casecmp("true").zero?
7
7
  # :nocov:
8
8
  require "version_gem"
9
- require_relative "dev/version"
10
9
 
11
10
  module Kettle
12
11
  module Dev
@@ -22,6 +21,11 @@ module Kettle
22
21
  # Whether to benchmark requires with require_bench.
23
22
  # @return [Boolean]
24
23
  REQUIRE_BENCH = ENV.fetch("REQUIRE_BENCH", "false").casecmp("true").zero?
24
+ # Whether to load rake tasks at the bottom of this file.
25
+ # Normally they would be loaded in the project's Rakefile,
26
+ # but if we do that in this project then we can't get accurate code coverage.
27
+ # @return [Boolean]
28
+ RUNNING_AS = File.basename($PROGRAM_NAME)
25
29
 
26
30
  @defaults = []
27
31
 
@@ -103,6 +107,31 @@ module Kettle
103
107
  end
104
108
  end
105
109
 
110
+ # Autoload public CLI/APIs so requiring "kettle-dev" exposes them lazily
111
+ # for tests and executables. Files will be loaded on first constant access.
112
+ module Kettle
113
+ autoload :EmojiRegex, "kettle/emoji_regex"
114
+ module Dev
115
+ autoload :CIHelpers, "kettle/dev/ci_helpers"
116
+ autoload :CommitMsg, "kettle/dev/commit_msg"
117
+ autoload :GitCommitFooter, "kettle/dev/git_commit_footer"
118
+ autoload :ReadmeBackers, "kettle/dev/readme_backers"
119
+ autoload :ReleaseCLI, "kettle/dev/release_cli"
120
+ autoload :TemplateHelpers, "kettle/dev/template_helpers"
121
+ autoload :ExitAdapter, "kettle/dev/exit_adapter"
122
+ autoload :Version, "kettle/dev/version"
123
+
124
+ # Nested tasks namespace with autoloaded task modules
125
+ module Tasks
126
+ autoload :TemplateTask, "kettle/dev/tasks/template_task"
127
+ autoload :InstallTask, "kettle/dev/tasks/install_task"
128
+ autoload :CITask, "kettle/dev/tasks/ci_task"
129
+ end
130
+ end
131
+ end
132
+
106
133
  Kettle::Dev::Version.class_eval do
107
134
  extend VersionGem::Basic
108
135
  end
136
+
137
+ Kettle::Dev.install_tasks if Kettle::Dev::RUNNING_AS == "rake"
data/lib/kettle-dev.rb CHANGED
@@ -15,9 +15,8 @@
15
15
  # Hook for other libraries to load this library (e.g. via bundler)
16
16
  #
17
17
  # @example In your spec/spec_helper.rb
18
- # require "kettle-dev"
18
+ # require "kettle-dev" # or require "kettle/dev"
19
19
  # @example In your Rakefile
20
- # require "kettle/dev"
21
- # Kettle::Dev.install_tasks
20
+ # require "kettle-dev" # or require "kettle/dev"
22
21
  require "kettle/dev"
23
22
  # rubocop:enable Naming/FileName
@@ -1,14 +1,11 @@
1
1
  module Kettle
2
2
  module Dev
3
3
  module CIHelpers
4
+ # singleton (module) methods
4
5
  def self.project_root: () -> String
5
-
6
6
  def self.repo_info: () -> [String, String]?
7
-
8
7
  def self.current_branch: () -> String?
9
-
10
8
  def self.workflows_list: (?String root) -> Array[String]
11
-
12
9
  def self.exclusions: () -> Array[String]
13
10
 
14
11
  def self.latest_run: (
@@ -17,32 +14,26 @@ module Kettle
17
14
  workflow_file: String,
18
15
  ?branch: String?,
19
16
  ?token: String?
20
- ) -> { "status" => String?, "conclusion" => String?, "html_url" => String?, "id" => untyped }?
21
-
22
- def self.success?: ({ "status" => String?, "conclusion" => String? } | nil run) -> bool
17
+ ) -> { "status" => String, "conclusion" => String?, "html_url" => String, "id" => Integer }?
23
18
 
24
- def self.failed?: ({ "status" => String?, "conclusion" => String? } | nil run) -> bool
19
+ def self.success?: ({ "status" => String, "conclusion" => String? }?) -> bool
20
+ def self.failed?: ({ "status" => String, "conclusion" => String? }?) -> bool
25
21
 
26
22
  def self.default_token: () -> String?
27
23
 
28
- # GitLab support
24
+ # GitLab
29
25
  def self.origin_url: () -> String?
30
-
31
26
  def self.repo_info_gitlab: () -> [String, String]?
32
-
33
27
  def self.default_gitlab_token: () -> String?
34
-
35
28
  def self.gitlab_latest_pipeline: (
36
29
  owner: String,
37
30
  repo: String,
38
31
  ?branch: String?,
39
32
  ?host: String,
40
33
  ?token: String?
41
- ) -> { "status" => String?, "web_url" => String?, "id" => untyped }?
42
-
43
- def self.gitlab_success?: ({ "status" => String? } | nil pipeline) -> bool
44
-
45
- def self.gitlab_failed?: ({ "status" => String? } | nil pipeline) -> bool
34
+ ) -> { "status" => String, "web_url" => String, "id" => Integer }?
35
+ def self.gitlab_success?: ({ "status" => String }?) -> bool
36
+ def self.gitlab_failed?: ({ "status" => String }?) -> bool
46
37
  end
47
38
  end
48
39
  end
@@ -0,0 +1,8 @@
1
+ module Kettle
2
+ module Dev
3
+ module CommitMsg
4
+ BRANCH_RULES: ::Hash[String, ::Regexp]
5
+ def self.enforce_branch_rule!: (String path) -> void
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Kettle
2
+ module Dev
3
+ module ExitAdapter
4
+ def self.abort: (String msg) -> void
5
+ def self.exit: (?Integer status) -> void
6
+ end
7
+ end
8
+ end