kettle-dev 1.1.4 → 1.1.5

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 (45) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.env.local.example +14 -1
  4. data/.envrc +2 -2
  5. data/.git-hooks/commit-msg +22 -16
  6. data/.git-hooks/prepare-commit-msg.example +19 -0
  7. data/.github/workflows/coverage.yml +2 -2
  8. data/.junie/guidelines.md +4 -0
  9. data/.opencollective.yml.example +3 -0
  10. data/CHANGELOG.md +31 -1
  11. data/CONTRIBUTING.md +29 -0
  12. data/FUNDING.md +2 -2
  13. data/README.md +148 -38
  14. data/README.md.example +7 -7
  15. data/Rakefile.example +1 -1
  16. data/exe/kettle-changelog +7 -2
  17. data/exe/kettle-commit-msg +20 -5
  18. data/exe/kettle-dev-setup +2 -1
  19. data/exe/kettle-dvcs +7 -2
  20. data/exe/kettle-pre-release +66 -0
  21. data/exe/kettle-readme-backers +7 -2
  22. data/exe/kettle-release +9 -2
  23. data/lib/kettle/dev/changelog_cli.rb +4 -5
  24. data/lib/kettle/dev/ci_helpers.rb +9 -5
  25. data/lib/kettle/dev/ci_monitor.rb +229 -8
  26. data/lib/kettle/dev/gem_spec_reader.rb +105 -39
  27. data/lib/kettle/dev/git_adapter.rb +6 -3
  28. data/lib/kettle/dev/git_commit_footer.rb +5 -2
  29. data/lib/kettle/dev/pre_release_cli.rb +248 -0
  30. data/lib/kettle/dev/readme_backers.rb +4 -2
  31. data/lib/kettle/dev/release_cli.rb +27 -17
  32. data/lib/kettle/dev/tasks/ci_task.rb +112 -22
  33. data/lib/kettle/dev/tasks/install_task.rb +23 -17
  34. data/lib/kettle/dev/tasks/template_task.rb +64 -23
  35. data/lib/kettle/dev/template_helpers.rb +44 -31
  36. data/lib/kettle/dev/version.rb +1 -1
  37. data/lib/kettle/dev.rb +5 -0
  38. data/sig/kettle/dev/ci_monitor.rbs +6 -0
  39. data/sig/kettle/dev/gem_spec_reader.rbs +8 -5
  40. data/sig/kettle/dev/pre_release_cli.rbs +20 -0
  41. data/sig/kettle/dev/template_helpers.rbs +2 -0
  42. data/sig/kettle/dev.rbs +1 -0
  43. data.tar.gz.sig +0 -0
  44. metadata +30 -4
  45. metadata.gz.sig +0 -0
@@ -55,12 +55,7 @@ module Kettle
55
55
  dynamic_files.each { |f| display_code_for[f] = "" }
56
56
 
57
57
  status_emoji = proc do |status, conclusion|
58
- case status
59
- when "queued" then "⏳️"
60
- when "in_progress" then "👟"
61
- when "completed" then ((conclusion == "success") ? "✅" : "🍅")
62
- else "⏳️"
63
- end
58
+ Kettle::Dev::CIMonitor.status_emoji(status, conclusion)
64
59
  end
65
60
 
66
61
  fetch_and_print_status = proc do |workflow_file|
@@ -98,6 +93,52 @@ module Kettle
98
93
  end
99
94
  end
100
95
 
96
+ # Print GitLab pipeline status (if configured) for the current branch.
97
+ print_gitlab_status = proc do
98
+ begin
99
+ branch = Kettle::Dev::CIHelpers.current_branch
100
+ # Detect any GitLab remote (not just origin), mirroring CIMonitor behavior
101
+ gl_remotes = Kettle::Dev::CIMonitor.gitlab_remote_candidates
102
+ if gl_remotes.nil? || gl_remotes.empty? || branch.nil?
103
+ puts "Latest GL (#{branch || "n/a"}) pipeline: n/a"
104
+ next
105
+ end
106
+
107
+ # Parse owner/repo from the first GitLab remote URL
108
+ gl_url = Kettle::Dev::CIMonitor.remote_url(gl_remotes.first)
109
+ owner = repo = nil
110
+ if gl_url =~ %r{git@gitlab.com:(.+?)/(.+?)(\.git)?$}
111
+ owner = Regexp.last_match(1)
112
+ repo = Regexp.last_match(2).sub(/\.git\z/, "")
113
+ elsif gl_url =~ %r{https://gitlab.com/(.+?)/(.+?)(\.git)?$}
114
+ owner = Regexp.last_match(1)
115
+ repo = Regexp.last_match(2).sub(/\.git\z/, "")
116
+ end
117
+
118
+ unless owner && repo
119
+ puts "Latest GL (#{branch}) pipeline: n/a"
120
+ next
121
+ end
122
+
123
+ pipe = Kettle::Dev::CIHelpers.gitlab_latest_pipeline(owner: owner, repo: repo, branch: branch)
124
+ if pipe
125
+ st = pipe["status"].to_s
126
+ status = if st == "success"
127
+ "success"
128
+ else
129
+ ((st == "failed") ? "failure" : nil)
130
+ end
131
+ emoji = Kettle::Dev::CIMonitor.status_emoji(st, status)
132
+ details = [st, pipe["failure_reason"]].compact.join("/")
133
+ puts "Latest GL (#{branch}) pipeline: #{emoji} (#{details})"
134
+ else
135
+ puts "Latest GL (#{branch}) pipeline: none"
136
+ end
137
+ rescue StandardError => e
138
+ puts "GL status: error #{e.class}: #{e.message}"
139
+ end
140
+ end
141
+
101
142
  run_act_for = proc do |file_path|
102
143
  ok = system("act", "-W", file_path)
103
144
  task_abort("ci:act failed: 'act' command not found or exited with failure") unless ok
@@ -131,6 +172,7 @@ module Kettle
131
172
  task_abort("ci:act aborted")
132
173
  end
133
174
  fetch_and_print_status.call(file)
175
+ print_gitlab_status.call
134
176
  run_act_for.call(file_path)
135
177
  return
136
178
  end
@@ -152,13 +194,15 @@ module Kettle
152
194
  upstream = begin
153
195
  out, status = Open3.capture2("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
154
196
  status.success? ? out.strip : nil
155
- rescue StandardError
197
+ rescue StandardError => e
198
+ Kettle::Dev.debug_error(e, __method__)
156
199
  nil
157
200
  end
158
201
  sha = begin
159
202
  out, status = Open3.capture2("git", "rev-parse", "--short", "HEAD")
160
203
  status.success? ? out.strip : nil
161
- rescue StandardError
204
+ rescue StandardError => e
205
+ Kettle::Dev.debug_error(e, __method__)
162
206
  nil
163
207
  end
164
208
  if org && branch
@@ -170,6 +214,36 @@ module Kettle
170
214
  end
171
215
  puts "Upstream: #{upstream || "n/a"}"
172
216
  puts "HEAD: #{sha || "n/a"}"
217
+
218
+ # Compare remote HEAD SHAs between GitHub and GitLab for current branch and highlight mismatch
219
+ begin
220
+ branch_name = branch
221
+ if branch_name
222
+ gh_remote = Kettle::Dev::CIMonitor.preferred_github_remote
223
+ gl_remote = Kettle::Dev::CIMonitor.gitlab_remote_candidates.first
224
+ gh_sha = nil
225
+ gl_sha = nil
226
+ if gh_remote
227
+ out, status = Open3.capture2("git", "ls-remote", gh_remote.to_s, "refs/heads/#{branch_name}")
228
+ gh_sha = out.split(/\s+/).first if status.success? && out && !out.empty?
229
+ end
230
+ if gl_remote
231
+ out, status = Open3.capture2("git", "ls-remote", gl_remote.to_s, "refs/heads/#{branch_name}")
232
+ gl_sha = out.split(/\s+/).first if status.success? && out && !out.empty?
233
+ end
234
+ if gh_sha && gl_sha
235
+ gh_short = gh_sha[0, 7]
236
+ gl_short = gl_sha[0, 7]
237
+ if gh_short != gl_short
238
+ puts "⚠️ HEAD mismatch on #{branch_name}: GitHub #{gh_short} vs GitLab #{gl_short}"
239
+ end
240
+ end
241
+ end
242
+ rescue StandardError => e
243
+ Kettle::Dev.debug_error(e, __method__)
244
+ end
245
+
246
+ print_gitlab_status.call
173
247
  puts
174
248
  puts "Select a workflow to run with 'act':"
175
249
 
@@ -183,14 +257,18 @@ module Kettle
183
257
 
184
258
  puts "(Fetching latest GHA status for branch #{branch || "n/a"} — you can type your choice and press Enter)"
185
259
  prompt = "Enter number or code (or 'q' to quit): "
186
- print(prompt)
187
- $stdout.flush
260
+ if tty
261
+ print(prompt)
262
+ $stdout.flush
263
+ end
188
264
 
189
265
  # We need to sleep a bit here to ensure the terminal is ready for both
190
266
  # input and writing status updates to each workflow's line
191
267
  sleep(0.2) unless Kettle::Dev::IS_CI
192
268
 
193
269
  selected = nil
270
+ # Create input thread always so specs that assert its cleanup/exception behavior can exercise it,
271
+ # but guard against non-interactive stdin by rescuing 'bad tty' and similar errors immediately.
194
272
  input_thread = Thread.new do
195
273
  begin
196
274
  selected = Kettle::Dev::InputAdapter.gets&.strip
@@ -198,7 +276,7 @@ module Kettle
198
276
  # Catch all exceptions in background thread, including SystemExit
199
277
  # NOTE: look into refactoring to minimize potential SystemExit.
200
278
  puts "Error in background thread: #{error.class}: #{error.message}" if Kettle::Dev::DEBUGGING
201
- selected = nil
279
+ selected = :input_error
202
280
  end
203
281
  end
204
282
 
@@ -231,12 +309,7 @@ module Kettle
231
309
  if run
232
310
  st = run["status"]
233
311
  con = run["conclusion"]
234
- emoji = case st
235
- when "queued" then "⏳️"
236
- when "in_progress" then "👟"
237
- when "completed" then ((con == "success") ? "✅" : "🍅")
238
- else "⏳️"
239
- end
312
+ emoji = Kettle::Dev::CIMonitor.status_emoji(st, con)
240
313
  details = [st, con].compact.join("/")
241
314
  status_q << [c, f, "#{emoji} (#{details})"]
242
315
  break if st == "completed"
@@ -247,13 +320,15 @@ module Kettle
247
320
  else
248
321
  status_q << [c, f, "fail #{res.code}"]
249
322
  end
250
- rescue Exception
323
+ rescue Exception => e # rubocop:disable Lint/RescueException
324
+ Kettle::Dev.debug_error(e, __method__)
251
325
  # Catch all exceptions to prevent crashing the process from a worker thread
252
326
  status_q << [c, f, "err"]
253
327
  end
254
328
  sleep(poll_interval)
255
329
  end
256
- rescue Exception
330
+ rescue Exception => e # rubocop:disable Lint/RescueException
331
+ Kettle::Dev.debug_error(e, __method__)
257
332
  # :nocov:
258
333
  # Catch all exceptions in the worker thread boundary, including SystemExit
259
334
  status_q << [c, f, "err"]
@@ -264,11 +339,22 @@ module Kettle
264
339
 
265
340
  statuses = Hash.new(placeholder)
266
341
 
342
+ # In non-interactive environments (no TTY) and when not DEBUGGING, auto-quit after a short idle
343
+ auto_quit_deadline = if !tty && !Kettle::Dev::DEBUGGING
344
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1.0
345
+ end
346
+
267
347
  loop do
268
348
  if selected
269
349
  break
270
350
  end
271
351
 
352
+ # Auto-quit if deadline passed without input (non-interactive runs)
353
+ if auto_quit_deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= auto_quit_deadline
354
+ selected = "q"
355
+ break
356
+ end
357
+
272
358
  begin
273
359
  code, file_name, display = status_q.pop(true)
274
360
  statuses[code] = display
@@ -291,20 +377,24 @@ module Kettle
291
377
  puts "status #{code}: #{display}"
292
378
  end
293
379
  rescue ThreadError
380
+ # ThreadError is raised when the queue is empty,
381
+ # and it needs to be silent to maintain the output row alignment
294
382
  sleep(0.05)
295
383
  end
296
384
  end
297
385
 
298
386
  begin
299
387
  workers.each { |t| t.kill if t&.alive? }
300
- rescue StandardError
388
+ rescue StandardError => e
389
+ Kettle::Dev.debug_error(e, __method__)
301
390
  end
302
391
  begin
303
392
  input_thread.kill if input_thread&.alive?
304
- rescue StandardError
393
+ rescue StandardError => e
394
+ Kettle::Dev.debug_error(e, __method__)
305
395
  end
306
396
 
307
- input = selected
397
+ input = (selected == :input_error) ? nil : selected
308
398
  task_abort("ci:act aborted: no selection") if input.nil? || input.empty?
309
399
 
310
400
  chosen_file = nil
@@ -61,7 +61,8 @@ module Kettle
61
61
  content = content.gsub(/!\[[^\]]*?\]\s*\[#{label_re}\]/, "")
62
62
  removed_labels << label
63
63
  end
64
- rescue StandardError
64
+ rescue StandardError => e
65
+ Kettle::Dev.debug_error(e, __method__)
65
66
  # ignore
66
67
  end
67
68
  end
@@ -201,7 +202,8 @@ module Kettle
201
202
  tmp = tmp[cluster.length..-1].to_s
202
203
  end
203
204
  rest_wo_emoji = tmp.sub(/\A\s+/, "")
204
- rescue StandardError
205
+ rescue StandardError => e
206
+ Kettle::Dev.debug_error(e, __method__)
205
207
  rest_wo_emoji = rest.sub(/\A\s+/, "")
206
208
  end
207
209
  # Build H1 with single spaces only around separators; preserve inner spacing in rest_wo_emoji
@@ -236,7 +238,8 @@ module Kettle
236
238
  end
237
239
  tmp = tmp.sub(/\A\s+/, "")
238
240
  body_wo = tmp
239
- rescue StandardError
241
+ rescue StandardError => e
242
+ Kettle::Dev.debug_error(e, __method__)
240
243
  body_wo = body.sub(/\A\s+/, "")
241
244
  end
242
245
  pre + q + ("#{chosen_grapheme} " + body_wo) + q
@@ -272,7 +275,8 @@ module Kettle
272
275
  end.join
273
276
  File.open(readme_path, "w") { |f| f.write(content) }
274
277
  end
275
- rescue StandardError
278
+ rescue StandardError => e
279
+ Kettle::Dev.debug_error(e, __method__)
276
280
  # ignore whitespace normalization errors
277
281
  end
278
282
 
@@ -335,14 +339,14 @@ module Kettle
335
339
  if interpolated || !valid_literal
336
340
  puts
337
341
  puts "Checking git remote 'origin' to derive GitHub homepage..."
338
- origin_url = nil
342
+ origin_url = ""
343
+ # Use GitAdapter to avoid hanging and to simplify testing.
339
344
  begin
340
- origin_cmd = ["git", "-C", project_root.to_s, "remote", "get-url", "origin"]
341
- origin_out = IO.popen(origin_cmd, &:read)
342
- origin_out = origin_out.read if origin_out.respond_to?(:read)
343
- origin_url = origin_out.to_s.strip
344
- rescue StandardError
345
- origin_url = ""
345
+ ga = Kettle::Dev::GitAdapter.new
346
+ origin_url = ga.remote_url("origin") || ga.remotes_with_urls["origin"]
347
+ origin_url = origin_url.to_s.strip
348
+ rescue StandardError => e
349
+ Kettle::Dev.debug_error(e, __method__)
346
350
  end
347
351
 
348
352
  org_repo = github_repo_from_url.call(origin_url)
@@ -363,7 +367,7 @@ module Kettle
363
367
  puts "Suggested literal homepage: \"#{suggested}\""
364
368
  print("Update #{File.basename(gemspec_path)} to use this homepage? [Y/n]: ")
365
369
  ans = Kettle::Dev::InputAdapter.gets&.strip
366
- do_update = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
370
+ do_update = if ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
367
371
  true
368
372
  else
369
373
  ans.nil? || ans.empty? || ans =~ /\Ay(es)?\z/i
@@ -440,7 +444,8 @@ module Kettle
440
444
  else
441
445
  begin
442
446
  current = File.file?(envrc_path) ? File.read(envrc_path) : ""
443
- rescue StandardError
447
+ rescue StandardError => e
448
+ Kettle::Dev.debug_error(e, __method__)
444
449
  current = ""
445
450
  end
446
451
  has_path_add = current.lines.any? { |l| l.strip =~ /^PATH_add\s+bin\b/ }
@@ -468,8 +473,8 @@ module Kettle
468
473
  end
469
474
 
470
475
  if defined?(updated_envrc_by_install) && updated_envrc_by_install
471
- allowed_truthy = ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
472
- force_truthy = ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
476
+ allowed_truthy = ENV.fetch("allowed", "").to_s =~ ENV_TRUE_RE
477
+ force_truthy = ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
473
478
  if allowed_truthy || force_truthy
474
479
  reason = allowed_truthy ? "allowed=true" : "force=true"
475
480
  puts "Proceeding after .envrc update because #{reason}."
@@ -496,7 +501,8 @@ module Kettle
496
501
  unless helpers.modified_by_template?(gitignore_path)
497
502
  begin
498
503
  gitignore_current = File.exist?(gitignore_path) ? File.read(gitignore_path) : ""
499
- rescue StandardError
504
+ rescue StandardError => e
505
+ Kettle::Dev.debug_error(e, __method__)
500
506
  gitignore_current = ""
501
507
  end
502
508
  has_env_local = gitignore_current.lines.any? { |l| l.strip == ".env.local" }
@@ -505,7 +511,7 @@ module Kettle
505
511
  puts "Would you like to add '.env.local' to #{gitignore_path}?"
506
512
  print("Add to .gitignore now [Y/n]: ")
507
513
  answer = Kettle::Dev::InputAdapter.gets&.strip
508
- add_it = if ENV.fetch("force", "").to_s =~ /\A(1|true|y|yes)\z/i
514
+ add_it = if ENV.fetch("force", "").to_s =~ ENV_TRUE_RE
509
515
  true
510
516
  else
511
517
  answer.nil? || answer.empty? || answer =~ /\Ay(es)?\z/i
@@ -63,6 +63,7 @@ module Kettle
63
63
  helpers.apply_common_replacements(
64
64
  c,
65
65
  org: forge_org,
66
+ funding_org: funding_org,
66
67
  gem_name: gem_name,
67
68
  namespace: namespace,
68
69
  namespace_shield: namespace_shield,
@@ -74,6 +75,7 @@ module Kettle
74
75
  helpers.apply_common_replacements(
75
76
  content,
76
77
  org: forge_org,
78
+ funding_org: funding_org,
77
79
  gem_name: gem_name,
78
80
  namespace: namespace,
79
81
  namespace_shield: namespace_shield,
@@ -209,21 +211,29 @@ module Kettle
209
211
  if File.exist?(dest_gemspec)
210
212
  begin
211
213
  orig_meta = helpers.gemspec_metadata(File.dirname(dest_gemspec))
212
- rescue StandardError
214
+ rescue StandardError => e
215
+ Kettle::Dev.debug_error(e, __method__)
213
216
  orig_meta = nil
214
217
  end
215
218
  end
216
219
 
217
220
  helpers.copy_file_with_prompt(gemspec_template_src, dest_gemspec, allow_create: true, allow_replace: true) do |content|
218
- # First apply standard replacements from the template example
219
- c = helpers.apply_common_replacements(
220
- content,
221
- org: forge_org,
222
- gem_name: gem_name,
223
- namespace: namespace,
224
- namespace_shield: namespace_shield,
225
- gem_shield: gem_shield,
226
- )
221
+ # First apply standard replacements from the template example, but only
222
+ # when we have a usable gem_name. If gem_name is unknown, leave content as-is
223
+ # to allow filename fallback behavior without raising.
224
+ c = if gem_name && !gem_name.to_s.empty?
225
+ helpers.apply_common_replacements(
226
+ content,
227
+ org: forge_org,
228
+ funding_org: funding_org,
229
+ gem_name: gem_name,
230
+ namespace: namespace,
231
+ namespace_shield: namespace_shield,
232
+ gem_shield: gem_shield,
233
+ )
234
+ else
235
+ content.dup
236
+ end
227
237
 
228
238
  if orig_meta
229
239
  # Replace a scalar string assignment like: spec.field = "..."
@@ -311,10 +321,32 @@ module Kettle
311
321
  end
312
322
  end
313
323
 
324
+ # Ensure we do not introduce a self-dependency when templating the gemspec.
325
+ # If the template included a dependency on the template gem (e.g., "kettle-dev"),
326
+ # the common replacements would have turned it into the destination gem's name.
327
+ # Strip any dependency lines that name the destination gem.
328
+ begin
329
+ if gem_name && !gem_name.to_s.empty?
330
+ name_escaped = Regexp.escape(gem_name)
331
+ # Matches both runtime and development dependency lines, with or without parentheses.
332
+ # Examples matched:
333
+ # spec.add_dependency("my-gem", "~> 1.0")
334
+ # spec.add_dependency 'my-gem'
335
+ # spec.add_development_dependency "my-gem"
336
+ # spec.add_development_dependency 'my-gem', ">= 0"
337
+ self_dep_re = /\A\s*spec\.add_(?:development_)?dependency(?:\s*\(|\s+)\s*["']#{name_escaped}["'][^\n]*\)?\s*\z/
338
+ c = c.lines.reject { |ln| self_dep_re =~ ln }.join
339
+ end
340
+ rescue StandardError => e
341
+ Kettle::Dev.debug_error(e, __method__)
342
+ # If anything goes wrong, keep the content as-is rather than failing the task
343
+ end
344
+
314
345
  c
315
346
  end
316
347
  end
317
- rescue StandardError
348
+ rescue StandardError => e
349
+ Kettle::Dev.debug_error(e, __method__)
318
350
  # Do not fail the entire template task if gemspec copy has issues
319
351
  end
320
352
 
@@ -349,7 +381,8 @@ module Kettle
349
381
  existing_readme_before = begin
350
382
  path = File.join(project_root, "README.md")
351
383
  File.file?(path) ? File.read(path) : nil
352
- rescue StandardError
384
+ rescue StandardError => e
385
+ Kettle::Dev.debug_error(e, __method__)
353
386
  nil
354
387
  end
355
388
 
@@ -372,7 +405,7 @@ module Kettle
372
405
  loop do
373
406
  cluster = s[/\A\X/u]
374
407
  break if cluster.nil? || cluster.empty?
375
- if emoji_re.match?(cluster)
408
+ if emoji_re =~ cluster
376
409
  out << cluster
377
410
  s = s[cluster.length..-1].to_s
378
411
  else
@@ -397,6 +430,7 @@ module Kettle
397
430
  c = helpers.apply_common_replacements(
398
431
  content,
399
432
  org: forge_org,
433
+ funding_org: funding_org,
400
434
  gem_name: gem_name,
401
435
  namespace: namespace,
402
436
  namespace_shield: namespace_shield,
@@ -539,7 +573,8 @@ module Kettle
539
573
  helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
540
574
  c = helpers.apply_common_replacements(
541
575
  content,
542
- org: ((File.basename(rel) == ".opencollective.yml" || File.basename(rel) == "FUNDING.md") ? funding_org : forge_org),
576
+ org: forge_org,
577
+ funding_org: funding_org,
543
578
  gem_name: gem_name,
544
579
  namespace: namespace,
545
580
  namespace_shield: namespace_shield,
@@ -581,7 +616,8 @@ module Kettle
581
616
  end
582
617
  end
583
618
  end
584
- rescue StandardError
619
+ rescue StandardError => e
620
+ Kettle::Dev.debug_error(e, __method__)
585
621
  # ignore post-processing errors
586
622
  end
587
623
 
@@ -656,13 +692,15 @@ module Kettle
656
692
  end
657
693
  end
658
694
  end
659
- rescue StandardError
695
+ rescue StandardError => e
696
+ Kettle::Dev.debug_error(e, __method__)
660
697
  # If filter parsing fails, proceed as before
661
698
  end
662
- goalie_src = File.join(source_hooks_dir, "commit-subjects-goalie.txt")
663
- footer_src = File.join(source_hooks_dir, "footer-template.erb.txt")
664
- hook_ruby_src = File.join(source_hooks_dir, "commit-msg")
665
- hook_sh_src = File.join(source_hooks_dir, "prepare-commit-msg")
699
+ # Prefer .example variant when present for .git-hooks
700
+ goalie_src = helpers.prefer_example(File.join(source_hooks_dir, "commit-subjects-goalie.txt"))
701
+ footer_src = helpers.prefer_example(File.join(source_hooks_dir, "footer-template.erb.txt"))
702
+ hook_ruby_src = helpers.prefer_example(File.join(source_hooks_dir, "commit-msg"))
703
+ hook_sh_src = helpers.prefer_example(File.join(source_hooks_dir, "prepare-commit-msg"))
666
704
 
667
705
  # First: templates (.txt) — ask local/global/skip
668
706
  if File.file?(goalie_src) && File.file?(footer_src)
@@ -708,7 +746,8 @@ module Kettle
708
746
  # Ensure readable (0644). These are data/templates, not executables.
709
747
  begin
710
748
  File.chmod(0o644, dest) if File.exist?(dest)
711
- rescue StandardError
749
+ rescue StandardError => e
750
+ Kettle::Dev.debug_error(e, __method__)
712
751
  # ignore permission issues
713
752
  end
714
753
  end
@@ -733,7 +772,8 @@ module Kettle
733
772
  helpers.write_file(dest, content)
734
773
  begin
735
774
  File.chmod(mode, dest)
736
- rescue StandardError
775
+ rescue StandardError => e
776
+ Kettle::Dev.debug_error(e, __method__)
737
777
  # ignore permission issues
738
778
  end
739
779
  puts "Replaced #{dest}"
@@ -745,7 +785,8 @@ module Kettle
745
785
  helpers.write_file(dest, content)
746
786
  begin
747
787
  File.chmod(mode, dest)
748
- rescue StandardError
788
+ rescue StandardError => e
789
+ Kettle::Dev.debug_error(e, __method__)
749
790
  # ignore permission issues
750
791
  end
751
792
  puts "Installed #{dest}"
@@ -12,6 +12,8 @@ module Kettle
12
12
  # Values: Hash with keys: :action (Symbol, one of :create, :replace, :skip, :dir_create, :dir_replace), :timestamp (Time)
13
13
  @@template_results = {}
14
14
 
15
+ EXECUTABLE_GIT_HOOKS_RE = %r{[\\/]\.git-hooks[\\/](commit-msg|prepare-commit-msg)\z}
16
+
15
17
  module_function
16
18
 
17
19
  # Root of the host project where Rake was invoked
@@ -119,9 +121,11 @@ module Kettle
119
121
  end
120
122
 
121
123
  if clean.nil?
122
- # Fallback to shelling out to get both status and preview
124
+ # Fallback to using the GitAdapter to get both status and preview
123
125
  status_output = begin
124
- IO.popen(["git", "-C", root.to_s, "status", "--porcelain"], &:read).to_s
126
+ ga = Kettle::Dev::GitAdapter.new
127
+ out, ok = ga.capture(["-C", root.to_s, "status", "--porcelain"]) # adapter can use CLI safely
128
+ ok ? out.to_s : ""
125
129
  rescue StandardError => e
126
130
  Kettle::Dev.debug_error(e, __method__)
127
131
  ""
@@ -130,9 +134,11 @@ module Kettle
130
134
  preview = status_output.lines.take(10).map(&:rstrip)
131
135
  else
132
136
  return if clean
133
- # For messaging, provide a small preview via porcelain even when using the adapter
137
+ # For messaging, provide a small preview using GitAdapter even when using the adapter
134
138
  status_output = begin
135
- IO.popen(["git", "-C", root.to_s, "status", "--porcelain"], &:read).to_s
139
+ ga = Kettle::Dev::GitAdapter.new
140
+ out, ok = ga.capture(["-C", root.to_s, "status", "--porcelain"]) # read-only query
141
+ ok ? out.to_s : ""
136
142
  rescue StandardError => e
137
143
  Kettle::Dev.debug_error(e, __method__)
138
144
  ""
@@ -223,10 +229,11 @@ module Kettle
223
229
  write_file(dest_path, content)
224
230
  begin
225
231
  # Ensure executable bit for git hook scripts when writing under .git-hooks
226
- if dest_path.to_s.match?(%r{[\\/]\.git-hooks[\\/](commit-msg|prepare-commit-msg)\z})
232
+ if EXECUTABLE_GIT_HOOKS_RE =~ dest_path.to_s
227
233
  File.chmod(0o755, dest_path) if File.exist?(dest_path)
228
234
  end
229
- rescue StandardError
235
+ rescue StandardError => e
236
+ Kettle::Dev.debug_error(e, __method__)
230
237
  # ignore permission issues
231
238
  end
232
239
  record_template_result(dest_path, dest_exists ? :replace : :create)
@@ -327,10 +334,11 @@ module Kettle
327
334
  begin
328
335
  # Ensure executable bit for git hook scripts when copying under .git-hooks
329
336
  if target.end_with?("/.git-hooks/commit-msg", "/.git-hooks/prepare-commit-msg") ||
330
- target.match?(%r{[\\/]\.git-hooks[\\/](commit-msg|prepare-commit-msg)\z})
337
+ EXECUTABLE_GIT_HOOKS_RE =~ target
331
338
  File.chmod(0o755, target)
332
339
  end
333
- rescue StandardError
340
+ rescue StandardError => e
341
+ Kettle::Dev.debug_error(e, __method__)
334
342
  # ignore permission issues
335
343
  end
336
344
  end
@@ -374,10 +382,11 @@ module Kettle
374
382
  begin
375
383
  # Ensure executable bit for git hook scripts when copying under .git-hooks
376
384
  if target.end_with?("/.git-hooks/commit-msg", "/.git-hooks/prepare-commit-msg") ||
377
- target.match?(%r{[\\/]\.git-hooks[\\/](commit-msg|prepare-commit-msg)\z})
385
+ EXECUTABLE_GIT_HOOKS_RE =~ target
378
386
  File.chmod(0o755, target)
379
387
  end
380
- rescue StandardError
388
+ rescue StandardError => e
389
+ Kettle::Dev.debug_error(e, __method__)
381
390
  # ignore permission issues
382
391
  end
383
392
  end
@@ -394,30 +403,34 @@ module Kettle
394
403
  # @param namespace [String]
395
404
  # @param namespace_shield [String]
396
405
  # @param gem_shield [String]
406
+ # @param funding_org [String, nil]
397
407
  # @return [String]
398
- def apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:)
399
- c = content.dup
400
- c = c.gsub("kettle-rb", org.to_s) if org && !org.empty?
401
- if gem_name && !gem_name.empty?
402
- # Special-case: yard-head link uses the gem name as a subdomain and must be dashes-only.
403
- # Apply this BEFORE other generic replacements so it isn't altered incorrectly.
404
- begin
405
- dashed = gem_name.tr("_", "-")
406
- c = c.gsub("[🚎yard-head]: https://kettle-dev.galtzo.com", "[🚎yard-head]: https://#{dashed}.galtzo.com")
407
- rescue StandardError
408
- # ignore
409
- end
408
+ def apply_common_replacements(content, org:, gem_name:, namespace:, namespace_shield:, gem_shield:, funding_org: nil)
409
+ raise Error, "Org could not be derived" unless org && !org.empty?
410
+ raise Error, "Gem name could not be derived" unless gem_name && !gem_name.empty?
410
411
 
411
- # Replace occurrences of the template gem name in text, including inside
412
- # markdown reference labels like [🖼️kettle-dev] and identifiers like kettle-dev-i
413
- c = c.gsub("kettle-dev", gem_name)
414
- c = c.gsub(/\bKettle::Dev\b/u, namespace) unless namespace.empty?
415
- c = c.gsub("Kettle%3A%3ADev", namespace_shield) unless namespace_shield.empty?
416
- c = c.gsub("kettle--dev", gem_shield)
417
- # Replace require and path structures with gem_name, modifying - to / if needed
418
- c = c.gsub("kettle/dev", gem_name.tr("-", "/"))
412
+ funding_org ||= org
413
+ c = content.dup
414
+ c = c.gsub("kettle-rb", org.to_s)
415
+ c = c.gsub("{OPENCOLLECTIVE|ORG_NAME}", funding_org)
416
+ # Special-case: yard-head link uses the gem name as a subdomain and must be dashes-only.
417
+ # Apply this BEFORE other generic replacements so it isn't altered incorrectly.
418
+ begin
419
+ dashed = gem_name.tr("_", "-")
420
+ c = c.gsub("[🚎yard-head]: https://kettle-dev.galtzo.com", "[🚎yard-head]: https://#{dashed}.galtzo.com")
421
+ rescue StandardError => e
422
+ Kettle::Dev.debug_error(e, __method__)
423
+ # ignore
419
424
  end
420
- c
425
+
426
+ # Replace occurrences of the template gem name in text, including inside
427
+ # markdown reference labels like [🖼️kettle-dev] and identifiers like kettle-dev-i
428
+ c = c.gsub("kettle-dev", gem_name)
429
+ c = c.gsub(/\bKettle::Dev\b/u, namespace) unless namespace.empty?
430
+ c = c.gsub("Kettle%3A%3ADev", namespace_shield) unless namespace_shield.empty?
431
+ c = c.gsub("kettle--dev", gem_shield)
432
+ # Replace require and path structures with gem_name, modifying - to / if needed
433
+ c.gsub("kettle/dev", gem_name.tr("-", "/"))
421
434
  end
422
435
 
423
436
  # Parse gemspec metadata and derive useful strings