Module: Kettle::Dev::TemplateHelpers

Defined in:
lib/kettle/dev/template_helpers.rb

Overview

Helpers shared by kettle:dev Rake tasks for templating and file ops.

Constant Summary collapse

EXECUTABLE_GIT_HOOKS_RE =
%r{[\\/]\.git-hooks[\\/](commit-msg|prepare-commit-msg)\z}
MIN_SETUP_RUBY =

The minimum Ruby supported by setup-ruby GHA

Gem::Version.create("2.3")
@@template_results =

Track results of templating actions across a single process run.
Keys: absolute destination paths (String)
Values: Hash with keys: :action (Symbol, one of :create, :replace, :skip, :dir_create, :dir_replace), :timestamp (Time)

{}

Class Method Summary collapse

Class Method Details

.apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:, funding_org: nil, min_ruby: nil) ⇒ String

Apply common token replacements used when templating text files

Parameters:

  • content (String)
  • org (String, nil)
  • gem_name (String)
  • namespace (String)
  • namespace_shield (String)
  • gem_shield (String)
  • funding_org (String, nil) (defaults to: nil)

Returns:

  • (String)

Raises:



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
# File 'lib/kettle/dev/template_helpers.rb', line 414

def apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:, funding_org: nil, min_ruby: nil)
  raise Error, "Org could not be derived" unless org && !org.empty?
  raise Error, "Gem name could not be derived" unless gem_name && !gem_name.empty?

  funding_org ||= org
  # Derive min_ruby if not provided
  mr = begin
    meta = 
    meta[:min_ruby]
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # leave min_ruby as-is (possibly nil)
  end
  if min_ruby.nil? || min_ruby.to_s.strip.empty?
    min_ruby = mr.respond_to?(:to_s) ? mr.to_s : mr
  end

  # Derive min_dev_ruby from min_ruby
  # min_dev_ruby is the greater of min_dev_ruby and ruby 2.3,
  #   because ruby 2.3 is the minimum ruby supported by setup-ruby GHA
  min_dev_ruby = begin
    [mr, MIN_SETUP_RUBY].max
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    MIN_SETUP_RUBY
  end

  c = content.dup
  c = c.gsub("kettle-rb", org.to_s)
  c = c.gsub("{OPENCOLLECTIVE|ORG_NAME}", funding_org)
  # Replace min ruby token if present
  begin
    if min_ruby && !min_ruby.to_s.empty? && c.include?("{K_D_MIN_RUBY}")
      c = c.gsub("{K_D_MIN_RUBY}", min_ruby.to_s)
    end
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # ignore
  end

  # Replace min ruby dev token if present
  begin
    if min_dev_ruby && !min_dev_ruby.to_s.empty? && c.include?("{K_D_MIN_DEV_RUBY}")
      c = c.gsub("{K_D_MIN_DEV_RUBY}", min_dev_ruby.to_s)
    end
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # ignore
  end

  # Special-case: yard-head link uses the gem name as a subdomain and must be dashes-only.
  # Apply this BEFORE other generic replacements so it isn't altered incorrectly.
  begin
    dashed = gem_name.tr("_", "-")
    c = c.gsub("[🚎yard-head]: https://kettle-dev.galtzo.com", "[🚎yard-head]: https://#{dashed}.galtzo.com")
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # ignore
  end

  # Replace occurrences of the template gem name in text, including inside
  # markdown reference labels like [🖼️kettle-dev] and identifiers like kettle-dev-i
  c = c.gsub("kettle-dev", gem_name)
  c = c.gsub(/\bKettle::Dev\b/u, namespace) unless namespace.empty?
  c = c.gsub("Kettle%3A%3ADev", namespace_shield) unless namespace_shield.empty?
  c = c.gsub("kettle--dev", gem_shield)
  # Replace require and path structures with gem_name, modifying - to / if needed
  c.gsub("kettle/dev", gem_name.tr("-", "/"))
end

.ask(prompt, default) ⇒ Boolean

Simple yes/no prompt.

Parameters:

  • prompt (String)
  • default (Boolean)

Returns:

  • (Boolean)


38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/kettle/dev/template_helpers.rb', line 38

def ask(prompt, default)
  # Force mode: any prompt resolves to Yes when ENV["force"] is set truthy
  if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
    puts "#{prompt} #{default ? "[Y/n]" : "[y/N]"}: Y (forced)"
    return true
  end
  print("#{prompt} #{default ? "[Y/n]" : "[y/N]"}: ")
  ans = Kettle::Dev::InputAdapter.gets&.strip
  ans = "" if ans.nil?
  # Normalize explicit no first
  return false if ans =~ /\An(o)?\z/i
  if default
    # Empty -> default true; explicit yes -> true; anything else -> false
    ans.empty? || ans =~ /\Ay(es)?\z/i
  else
    # Empty -> default false; explicit yes -> true; others (including garbage) -> false
    ans =~ /\Ay(es)?\z/i
  end
end

.copy_dir_with_prompt(src_dir, dest_dir) ⇒ void

This method returns an undefined value.

Copy a directory tree, prompting before creating or overwriting.



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# File 'lib/kettle/dev/template_helpers.rb', line 251

def copy_dir_with_prompt(src_dir, dest_dir)
  return unless Dir.exist?(src_dir)

  # Build a matcher for ENV["only"], relative to project root, that can be reused within this method
  only_raw = ENV["only"].to_s
  patterns = only_raw.split(",").map { |s| s.strip }.reject(&:empty?) unless only_raw.nil?
  patterns ||= []
  proj_root = project_root.to_s
  matches_only = lambda do |abs_dest|
    return true if patterns.empty?
    begin
      rel_dest = abs_dest.to_s
      if rel_dest.start_with?(proj_root + "/")
        rel_dest = rel_dest[(proj_root.length + 1)..-1]
      elsif rel_dest == proj_root
        rel_dest = ""
      end
      patterns.any? do |pat|
        if pat.end_with?("/**")
          base = pat[0..-4]
          rel_dest == base || rel_dest.start_with?(base + "/")
        else
          File.fnmatch?(pat, rel_dest, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
        end
      end
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      # On any error, do not filter out (act as matched)
      true
    end
  end

  # Early exit: if an only filter is present and no files inside this directory would match,
  # do not prompt to create/replace this directory at all.
  begin
    if !patterns.empty?
      any_match = false
      Find.find(src_dir) do |path|
        rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
        next if rel.empty?
        next if File.directory?(path)
        target = File.join(dest_dir, rel)
        if matches_only.call(target)
          any_match = true
          break
        end
      end
      unless any_match
        record_template_result(dest_dir, :skip)
        return
      end
    end
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # If determining matches fails, fall through to prompting logic
  end

  dest_exists = Dir.exist?(dest_dir)
  if dest_exists
    if ask("Replace directory #{dest_dir} (will overwrite files)?", true)
      Find.find(src_dir) do |path|
        rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
        next if rel.empty?
        target = File.join(dest_dir, rel)
        if File.directory?(path)
          FileUtils.mkdir_p(target)
        else
          # Per-file inclusion filter
          next unless matches_only.call(target)

          FileUtils.mkdir_p(File.dirname(target))
          if File.exist?(target)

            # Skip only if contents are identical. If source and target paths are the same,
            # avoid FileUtils.cp (which raises) and do an in-place rewrite to satisfy "copy".
            begin
              if FileUtils.compare_file(path, target)
                next
              elsif path == target
                data = File.binread(path)
                File.open(target, "wb") { |f| f.write(data) }
                next
              end
            rescue StandardError => e
              Kettle::Dev.debug_error(e, __method__)
              # ignore compare errors; fall through to copy
            end
          end
          FileUtils.cp(path, target)
          begin
            # Ensure executable bit for git hook scripts when copying under .git-hooks
            if target.end_with?("/.git-hooks/commit-msg", "/.git-hooks/prepare-commit-msg") ||
                EXECUTABLE_GIT_HOOKS_RE =~ target
              File.chmod(0o755, target)
            end
          rescue StandardError => e
            Kettle::Dev.debug_error(e, __method__)
            # ignore permission issues
          end
        end
      end
      puts "Updated #{dest_dir}"
      record_template_result(dest_dir, :dir_replace)
    else
      puts "Skipped #{dest_dir}"
      record_template_result(dest_dir, :skip)
    end
  elsif ask("Create directory #{dest_dir}?", true)
    FileUtils.mkdir_p(dest_dir)
    Find.find(src_dir) do |path|
      rel = path.sub(/^#{Regexp.escape(src_dir)}\/?/, "")
      next if rel.empty?
      target = File.join(dest_dir, rel)
      if File.directory?(path)
        FileUtils.mkdir_p(target)
      else
        # Per-file inclusion filter
        next unless matches_only.call(target)

        FileUtils.mkdir_p(File.dirname(target))
        if File.exist?(target)
          # Skip only if contents are identical. If source and target paths are the same,
          # avoid FileUtils.cp (which raises) and do an in-place rewrite to satisfy "copy".
          begin
            if FileUtils.compare_file(path, target)
              next
            elsif path == target
              data = File.binread(path)
              File.open(target, "wb") { |f| f.write(data) }
              next
            end
          rescue StandardError => e
            Kettle::Dev.debug_error(e, __method__)
            # ignore compare errors; fall through to copy
          end
        end
        FileUtils.cp(path, target)
        begin
          # Ensure executable bit for git hook scripts when copying under .git-hooks
          if target.end_with?("/.git-hooks/commit-msg", "/.git-hooks/prepare-commit-msg") ||
              EXECUTABLE_GIT_HOOKS_RE =~ target
            File.chmod(0o755, target)
          end
        rescue StandardError => e
          Kettle::Dev.debug_error(e, __method__)
          # ignore permission issues
        end
      end
    end
    puts "Created #{dest_dir}"
    record_template_result(dest_dir, :dir_create)
  end
end

.copy_file_with_prompt(src_path, dest_path, allow_create: true, allow_replace: true) ⇒ void

This method returns an undefined value.

Copy a single file with interactive prompts for create/replace.
Yields content for transformation when block given.



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/kettle/dev/template_helpers.rb', line 169

def copy_file_with_prompt(src_path, dest_path, allow_create: true, allow_replace: true)
  return unless File.exist?(src_path)

  # Apply optional inclusion filter via ENV["only"] (comma-separated glob patterns relative to project root)
  begin
    only_raw = ENV["only"].to_s
    if !only_raw.empty?
      patterns = only_raw.split(",").map { |s| s.strip }.reject(&:empty?)
      if !patterns.empty?
        proj = project_root.to_s
        rel_dest = dest_path.to_s
        if rel_dest.start_with?(proj + "/")
          rel_dest = rel_dest[(proj.length + 1)..-1]
        elsif rel_dest == proj
          rel_dest = ""
        end
        matched = patterns.any? do |pat|
          if pat.end_with?("/**")
            base = pat[0..-4]
            rel_dest == base || rel_dest.start_with?(base + "/")
          else
            File.fnmatch?(pat, rel_dest, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
          end
        end
        unless matched
          record_template_result(dest_path, :skip)
          puts "Skipping #{dest_path} (excluded by only filter)"
          return
        end
      end
    end
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # If anything goes wrong parsing/matching, ignore the filter and proceed.
  end

  dest_exists = File.exist?(dest_path)
  action = nil
  if dest_exists
    if allow_replace
      action = ask("Replace #{dest_path}?", true) ? :replace : :skip
    else
      puts "Skipping #{dest_path} (replace not allowed)."
      action = :skip
    end
  elsif allow_create
    action = ask("Create #{dest_path}?", true) ? :create : :skip
  else
    puts "Skipping #{dest_path} (create not allowed)."
    action = :skip
  end
  if action == :skip
    record_template_result(dest_path, :skip)
    return
  end

  content = File.read(src_path)
  content = yield(content) if block_given?
  # Final global replacements that must occur AFTER normal replacements
  begin
    token = "{KETTLE|DEV|GEM}"
    content = content.gsub(token, "kettle-dev") if content.include?(token)
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # If replacement fails unexpectedly, proceed with content as-is
  end
  write_file(dest_path, content)
  begin
    # Ensure executable bit for git hook scripts when writing under .git-hooks
    if EXECUTABLE_GIT_HOOKS_RE =~ dest_path.to_s
      File.chmod(0o755, dest_path) if File.exist?(dest_path)
    end
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    # ignore permission issues
  end
  record_template_result(dest_path, dest_exists ? :replace : :create)
  puts "Wrote #{dest_path}"
end

.ensure_clean_git!(root:, task_label:) ⇒ void

This method returns an undefined value.

Ensure git working tree is clean before making changes in a task.
If not a git repo, this is a no-op.

Parameters:

  • root (String)

    project root to run git commands in

  • task_label (String)

    name of the rake task for user-facing messages (e.g., “kettle:dev:install”)

Raises:



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/kettle/dev/template_helpers.rb', line 112

def ensure_clean_git!(root:, task_label:)
  inside_repo = begin
    system("git", "-C", root.to_s, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    false
  end
  return unless inside_repo

  # Prefer GitAdapter for cleanliness check; fallback to porcelain output
  clean = begin
    Dir.chdir(root.to_s) { Kettle::Dev::GitAdapter.new.clean? }
  rescue StandardError => e
    Kettle::Dev.debug_error(e, __method__)
    nil
  end

  if clean.nil?
    # Fallback to using the GitAdapter to get both status and preview
    status_output = begin
      ga = Kettle::Dev::GitAdapter.new
      out, ok = ga.capture(["-C", root.to_s, "status", "--porcelain"]) # adapter can use CLI safely
      ok ? out.to_s : ""
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      ""
    end
    return if status_output.strip.empty?
    preview = status_output.lines.take(10).map(&:rstrip)
  else
    return if clean
    # For messaging, provide a small preview using GitAdapter even when using the adapter
    status_output = begin
      ga = Kettle::Dev::GitAdapter.new
      out, ok = ga.capture(["-C", root.to_s, "status", "--porcelain"]) # read-only query
      ok ? out.to_s : ""
    rescue StandardError => e
      Kettle::Dev.debug_error(e, __method__)
      ""
    end
    preview = status_output.lines.take(10).map(&:rstrip)
  end

  puts "ERROR: Your git working tree has uncommitted changes."
  puts "#{task_label} may modify files (e.g., .github/, .gitignore, *.gemspec)."
  puts "Please commit or stash your changes, then re-run: rake #{task_label}"
  unless preview.empty?
    puts "Detected changes:"
    preview.each { |l| puts "  #{l}" }
    puts "(showing up to first 10 lines)"
  end
  raise Kettle::Dev::Error, "Aborting: git working tree is not clean."
end

.gem_checkout_rootString

Root of this gem’s checkout (repository root when working from source)
Calculated relative to lib/kettle/dev/

Returns:

  • (String)


30
31
32
# File 'lib/kettle/dev/template_helpers.rb', line 30

def gem_checkout_root
  File.expand_path("../../..", __dir__)
end

.gemspec_metadata(root = project_root) ⇒ Hash

Parse gemspec metadata and derive useful strings

Parameters:

  • root (String) (defaults to: project_root)

    project root

Returns:

  • (Hash)


487
488
489
# File 'lib/kettle/dev/template_helpers.rb', line 487

def (root = project_root)
  Kettle::Dev::GemSpecReader.load(root)
end

.modified_by_template?(dest_path) ⇒ Boolean

Returns true if the given path was created or replaced by the template task in this run

Parameters:

  • dest_path (String)

Returns:

  • (Boolean)


101
102
103
104
105
# File 'lib/kettle/dev/template_helpers.rb', line 101

def modified_by_template?(dest_path)
  rec = @@template_results[File.expand_path(dest_path.to_s)]
  return false unless rec
  [:create, :replace, :dir_create, :dir_replace].include?(rec[:action])
end

.prefer_example(src_path) ⇒ String

Prefer an .example variant for a given source path when present
For a given intended source path (e.g., “/src/Rakefile”), this will return
“/src/Rakefile.example” if it exists, otherwise returns the original path.
If the given path already ends with .example, it is returned as-is.

Parameters:

  • src_path (String)

Returns:

  • (String)


73
74
75
76
77
# File 'lib/kettle/dev/template_helpers.rb', line 73

def prefer_example(src_path)
  return src_path if src_path.end_with?(".example")
  example = src_path + ".example"
  File.exist?(example) ? example : src_path
end

.project_rootString

Root of the host project where Rake was invoked

Returns:

  • (String)


23
24
25
# File 'lib/kettle/dev/template_helpers.rb', line 23

def project_root
  CIHelpers.project_root
end

.record_template_result(dest_path, action) ⇒ void

This method returns an undefined value.

Record a template action for a destination path

Parameters:

  • dest_path (String)
  • action (Symbol)

    one of :create, :replace, :skip, :dir_create, :dir_replace



83
84
85
86
87
88
89
90
# File 'lib/kettle/dev/template_helpers.rb', line 83

def record_template_result(dest_path, action)
  abs = File.expand_path(dest_path.to_s)
  if action == :skip && @@template_results.key?(abs)
    # Preserve the last meaningful action; do not downgrade to :skip
    return
  end
  @@template_results[abs] = {action: action, timestamp: Time.now}
end

.template_resultsHash

Access all template results (read-only clone)

Returns:

  • (Hash)


94
95
96
# File 'lib/kettle/dev/template_helpers.rb', line 94

def template_results
  @@template_results.clone
end

.write_file(dest_path, content) ⇒ void

This method returns an undefined value.

Write file content creating directories as needed

Parameters:

  • dest_path (String)
  • content (String)


62
63
64
65
# File 'lib/kettle/dev/template_helpers.rb', line 62

def write_file(dest_path, content)
  FileUtils.mkdir_p(File.dirname(dest_path))
  File.open(dest_path, "w") { |f| f.write(content) }
end