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,401 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # vim: set syntax=ruby
5
+
6
+ # kettle-changelog: Generate a CHANGELOG.md entry for the current VERSION.
7
+ # - Reads VERSION from lib/**/version.rb (must be unique across files)
8
+ # - Moves entries from the "Unreleased" section into a new versioned section
9
+ # - Prepends 4 heading lines:
10
+ # - TAG
11
+ # - COVERAGE (line coverage)
12
+ # - BRANCH COVERAGE (branch coverage)
13
+ # - percent documented (parsed from `bin/yard` output)
14
+ # - Updates bottom link references to GitHub style, converts any existing
15
+ # GitLab links to GitHub links, and appends the new [X.Y.Z] and [X.Y.Zt] links.
16
+ #
17
+ # Notes:
18
+ # - Expects a JSON coverage report at coverage/coverage.json. If missing,
19
+ # it will instruct you to run: K_SOUP_COV_FORMATTERS="json" bin/rspec
20
+ # - Expects bin/yard to be available via Bundler.
21
+
22
+ $stdout.sync = true
23
+
24
+ # Depending library or project must be using bundler
25
+ require "bundler/setup"
26
+
27
+ require "json"
28
+ require "time"
29
+ require "open3"
30
+ require "shellwords"
31
+
32
+ begin
33
+ require "kettle/dev"
34
+ rescue LoadError => e
35
+ warn("kettle/dev: failed to load: #{e.message}")
36
+ warn("Hint: Ensure the host project includes kettle-dev and run bundle install.")
37
+ exit(1)
38
+ end
39
+
40
+ require "kettle/dev/versioning"
41
+
42
+ puts "== kettle-changelog v#{Kettle::Dev::Version::VERSION} =="
43
+
44
+ module Kettle
45
+ module Dev
46
+ class ChangelogCLI
47
+ def initialize
48
+ @root = Kettle::Dev::CIHelpers.project_root
49
+ @changelog_path = File.join(@root, "CHANGELOG.md")
50
+ @coverage_path = File.join(@root, "coverage", "coverage.json")
51
+ end
52
+
53
+ def run
54
+ version = Kettle::Dev::Versioning.detect_version(@root)
55
+ today = Time.now.strftime("%Y-%m-%d")
56
+ owner, repo = Kettle::Dev::CIHelpers.repo_info
57
+ unless owner && repo
58
+ warn("Could not determine GitHub owner/repo from origin remote.")
59
+ warn("Make sure 'origin' points to github.com. Alternatively, set origin or update links manually afterward.")
60
+ end
61
+
62
+ line_cov_line, branch_cov_line = coverage_lines
63
+ yard_line = yard_percent_documented
64
+
65
+ changelog = File.read(@changelog_path)
66
+
67
+ # If the detected version already exists in the changelog, abort to avoid duplicates
68
+ if changelog =~ /^## \[#{Regexp.escape(version)}\]/
69
+ abort("CHANGELOG.md already has a section for version #{version}. Bump version.rb or remove the duplicate.")
70
+ end
71
+
72
+ unreleased_block, before, after = extract_unreleased(changelog)
73
+ if unreleased_block.nil?
74
+ abort("Could not find '## [Unreleased]' section in CHANGELOG.md")
75
+ end
76
+
77
+ if unreleased_block.strip.empty?
78
+ warn("No entries found under Unreleased. Creating an empty version section anyway.")
79
+ end
80
+
81
+ prev_version = detect_previous_version(after)
82
+
83
+ new_section = +""
84
+ new_section << "## [#{version}] - #{today}\n"
85
+ new_section << "- TAG: [v#{version}][#{version}t]\n"
86
+ new_section << "- #{line_cov_line}\n" if line_cov_line
87
+ new_section << "- #{branch_cov_line}\n" if branch_cov_line
88
+ new_section << "- #{yard_line}\n" if yard_line
89
+ new_section << filter_unreleased_sections(unreleased_block)
90
+ # Ensure exactly one blank line separates this new section from the next section
91
+ new_section.rstrip!
92
+ new_section << "\n\n"
93
+
94
+ # Reset the Unreleased section to empty category headings
95
+ unreleased_reset = <<~MD
96
+ ## [Unreleased]
97
+ ### Added
98
+ ### Changed
99
+ ### Deprecated
100
+ ### Removed
101
+ ### Fixed
102
+ ### Security
103
+ MD
104
+
105
+ updated = before + unreleased_reset + "\n" + new_section + after
106
+
107
+ updated = update_link_refs(updated, owner, repo, prev_version, version)
108
+
109
+ File.write(@changelog_path, updated)
110
+ puts "CHANGELOG.md updated with v#{version} section."
111
+ end
112
+
113
+ private
114
+
115
+ def abort(msg)
116
+ Kettle::Dev::ExitAdapter.abort(msg)
117
+ rescue NameError
118
+ Kernel.abort(msg)
119
+ end
120
+
121
+ def detect_version
122
+ candidates = Dir[File.join(@root, "lib", "**", "version.rb")]
123
+ abort("Could not find version.rb under lib/**.") if candidates.empty?
124
+ versions = candidates.map do |path|
125
+ content = File.read(path)
126
+ m = content.match(/VERSION\s*=\s*(["'])([^"']+)\1/)
127
+ next unless m
128
+ m[2]
129
+ end.compact
130
+ abort("VERSION constant not found in #{@root}/lib/**/version.rb") if versions.none?
131
+ abort("Multiple VERSION constants found to be out of sync (#{versions.inspect}) in #{@root}/lib/**/version.rb") unless versions.uniq.length == 1
132
+ versions.first
133
+ end
134
+
135
+ def extract_unreleased(content)
136
+ lines = content.lines
137
+ start_i = lines.index { |l| l.start_with?("## [Unreleased]") }
138
+ return [nil, nil, nil] unless start_i
139
+ # Find the next version heading after Unreleased
140
+ next_i = (start_i + 1)
141
+ while next_i < lines.length && !lines[next_i].start_with?("## [")
142
+ next_i += 1
143
+ end
144
+ # Now next_i points to the next section heading or EOF
145
+ before = lines[0..(start_i - 1)].join
146
+ unreleased_block = lines[(start_i + 1)..(next_i - 1)].join
147
+ after = lines[next_i..-1]&.join || ""
148
+ [unreleased_block, before, after]
149
+ end
150
+
151
+ def detect_previous_version(after_text)
152
+ # after_text begins with the first released section following Unreleased
153
+ m = after_text.match(/^## \[(\d+\.\d+\.\d+)\]/)
154
+ return m[1] if m
155
+ nil
156
+ end
157
+
158
+ # From the Unreleased block, keep only sections that have content.
159
+ # We detect sections as lines starting with '### '. A section has content if there is at least
160
+ # one non-empty, non-heading line under it before the next '###' or '##'. Typically these are list items.
161
+ # Returns a string that includes only the non-empty sections with their content.
162
+ def filter_unreleased_sections(unreleased_block)
163
+ lines = unreleased_block.lines
164
+ out = []
165
+ i = 0
166
+ while i < lines.length
167
+ line = lines[i]
168
+ if line.start_with?("### ")
169
+ header = line
170
+ i += 1
171
+ chunk = []
172
+ while i < lines.length && !lines[i].start_with?("### ") && !lines[i].start_with?("## ")
173
+ chunk << lines[i]
174
+ i += 1
175
+ end
176
+ # Determine if chunk has any content (non-blank)
177
+ content_present = chunk.any? { |l| l.strip != "" }
178
+ if content_present
179
+ # Trim trailing blank lines
180
+ while chunk.any? && chunk.last.strip == ""
181
+ chunk.pop
182
+ end
183
+ out << header
184
+ out.concat(chunk)
185
+ out << "\n" unless out.last&.end_with?("\n")
186
+ end
187
+ next
188
+ else
189
+ # Lines outside sections are ignored for released sections
190
+ i += 1
191
+ end
192
+ end
193
+ out.join
194
+ end
195
+
196
+ def coverage_lines
197
+ unless File.file?(@coverage_path)
198
+ warn("Coverage JSON not found at #{@coverage_path}.")
199
+ warn("Run: K_SOUP_COV_FORMATTERS=\"json\" bin/rspec")
200
+ return [nil, nil]
201
+ end
202
+ data = JSON.parse(File.read(@coverage_path))
203
+ files = data["coverage"] || {}
204
+ file_count = 0
205
+ total_lines = 0
206
+ covered_lines = 0
207
+ total_branches = 0
208
+ covered_branches = 0
209
+ files.each_value do |h|
210
+ lines = h["lines"] || []
211
+ line_relevant = lines.count { |x| x.is_a?(Integer) }
212
+ line_covered = lines.count { |x| x.is_a?(Integer) && x > 0 }
213
+ if line_relevant > 0
214
+ file_count += 1
215
+ total_lines += line_relevant
216
+ covered_lines += line_covered
217
+ end
218
+ branches = h["branches"] || []
219
+ branches.each do |b|
220
+ next unless b.is_a?(Hash)
221
+ cov = b["coverage"]
222
+ next unless cov.is_a?(Numeric)
223
+ total_branches += 1
224
+ covered_branches += 1 if cov > 0
225
+ end
226
+ end
227
+ line_pct = (total_lines > 0) ? ((covered_lines.to_f / total_lines) * 100.0) : 0.0
228
+ branch_pct = (total_branches > 0) ? ((covered_branches.to_f / total_branches) * 100.0) : 0.0
229
+ line_str = format("COVERAGE: %.2f%% -- %d/%d lines in %d files", line_pct, covered_lines, total_lines, file_count)
230
+ branch_str = format("BRANCH COVERAGE: %.2f%% -- %d/%d branches in %d files", branch_pct, covered_branches, total_branches, file_count)
231
+ [line_str, branch_str]
232
+ rescue StandardError => e
233
+ warn("Failed to parse coverage: #{e.class}: #{e.message}")
234
+ [nil, nil]
235
+ end
236
+
237
+ def yard_percent_documented
238
+ cmd = File.join(@root, "bin", "yard")
239
+ unless File.executable?(cmd)
240
+ warn("bin/yard not found or not executable; ensure yard is installed via bundler")
241
+ return
242
+ end
243
+ out, _ = Open3.capture2(cmd)
244
+ # Look for a line containing e.g., "95.35% documented"
245
+ line = out.lines.find { |l| l =~ /\d+(?:\.\d+)?%\s+documented/ }
246
+ if line
247
+ line = line.strip
248
+ # Return exactly as requested: e.g. "95.35% documented"
249
+ line
250
+ else
251
+ warn("Could not find documented percentage in bin/yard output.")
252
+ nil
253
+ end
254
+ rescue StandardError => e
255
+ warn("Failed to run bin/yard: #{e.class}: #{e.message}")
256
+ nil
257
+ end
258
+
259
+ def update_link_refs(content, owner, repo, prev_version, new_version)
260
+ # Convert any GitLab links to GitHub
261
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/compare/([^\.]+)\.\.\.([^\s]+)}) do
262
+ o = owner || Regexp.last_match(1)
263
+ r = repo || Regexp.last_match(2)
264
+ from = Regexp.last_match(3)
265
+ to = Regexp.last_match(4)
266
+ "https://github.com/#{o}/#{r}/compare/#{from}...#{to}"
267
+ end
268
+ content = content.gsub(%r{https://gitlab\.com/([^/]+)/([^/]+)/-/tags/(v[^\s\]]+)}) do
269
+ o = owner || Regexp.last_match(1)
270
+ r = repo || Regexp.last_match(2)
271
+ tag = Regexp.last_match(3)
272
+ "https://github.com/#{o}/#{r}/releases/tag/#{tag}"
273
+ end
274
+
275
+ # Append or update the bottom reference links
276
+ lines = content.lines
277
+
278
+ # Find the index of the Unreleased heading; only manipulate refs after this point
279
+ unreleased_idx = lines.index { |l| l.start_with?("## [Unreleased]") } || -1
280
+
281
+ # Find the first link-ref line (e.g., "[Unreleased]: http...") AFTER Unreleased
282
+ first_ref = nil
283
+ lines.each_with_index do |l, i|
284
+ if l =~ /^\[[^\]]+\]:\s+http/ && i > unreleased_idx
285
+ first_ref = i
286
+ break
287
+ end
288
+ end
289
+ unless first_ref
290
+ # Append at end if no ref block after Unreleased
291
+ first_ref = lines.length
292
+ lines << "\n"
293
+ end
294
+
295
+ # Ensure Unreleased points to GitHub compare from new tag to HEAD
296
+ if owner && repo
297
+ unreleased_ref = "[Unreleased]: https://github.com/#{owner}/#{repo}/compare/v#{new_version}...HEAD\n"
298
+ # Update an existing Unreleased ref only if it appears after Unreleased heading; otherwise append
299
+ idx = nil
300
+ lines.each_with_index do |l, i|
301
+ if l.start_with?("[Unreleased]:") && i >= first_ref
302
+ idx = i
303
+ break
304
+ end
305
+ end
306
+ if idx
307
+ lines[idx] = unreleased_ref
308
+ else
309
+ lines << unreleased_ref
310
+ end
311
+ end
312
+
313
+ if owner && repo
314
+ # Add compare link for the new version
315
+ from = prev_version ? "v#{prev_version}" : detect_initial_compare_base(lines)
316
+ new_compare = "[#{new_version}]: https://github.com/#{owner}/#{repo}/compare/#{from}...v#{new_version}\n"
317
+ unless lines.any? { |l| l.start_with?("[#{new_version}]:") }
318
+ lines << new_compare
319
+ end
320
+ # Add tag link for the new version
321
+ new_tag = "[#{new_version}t]: https://github.com/#{owner}/#{repo}/releases/tag/v#{new_version}\n"
322
+ unless lines.any? { |l| l.start_with?("[#{new_version}t]:") }
323
+ lines << new_tag
324
+ end
325
+ end
326
+
327
+ # Rebuild and sort the reference block so newest is at the bottom, preserving everything above first_ref
328
+ ref_lines = lines[first_ref..-1].select { |l| l =~ /^\[[^\]]+\]:\s+http/ }
329
+ # Deduplicate by key (text inside the square brackets)
330
+ by_key = {}
331
+ ref_lines.each do |l|
332
+ if l =~ /^\[([^\]]+)\]:\s+/
333
+ by_key[$1] = l
334
+ end
335
+ end
336
+ unreleased_line = by_key.delete("Unreleased")
337
+ # Separate version compare and tag links
338
+ compares = {}
339
+ tags = {}
340
+ by_key.each do |k, v|
341
+ if k =~ /^(\d+\.\d+\.\d+)$/
342
+ compares[$1] = v
343
+ elsif k =~ /^(\d+\.\d+\.\d+)t$/
344
+ tags[$1] = v
345
+ end
346
+ end
347
+ # Sort versions ascending so newest at bottom
348
+ sorted_versions = compares.keys.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
349
+ # In case some versions only have tags or only compares, include them as well
350
+ (tags.keys - compares.keys).each { |s| sorted_versions |= [s] }
351
+ sorted_versions = sorted_versions.map { |s| Gem::Version.new(s) }.sort.map(&:to_s)
352
+
353
+ new_ref_block = []
354
+ new_ref_block << unreleased_line if unreleased_line
355
+ sorted_versions.each do |v|
356
+ new_ref_block << compares[v] if compares[v]
357
+ new_ref_block << tags[v] if tags[v]
358
+ end
359
+ # Replace the old block
360
+ rebuilt = lines[0...first_ref] + new_ref_block + ["\n"]
361
+ rebuilt.join
362
+ end
363
+
364
+ def detect_initial_compare_base(lines)
365
+ # Fallback when prev_version is unknown: try to find the first compare base used historically
366
+ # e.g., for 1.0.0 it may be a commit SHA instead of a tag
367
+ ref = lines.find { |l| l =~ /^\[1\.0\.0\]:\s+https:\/\/github\.com\// }
368
+ if ref && (m = ref.match(%r{compare/([^\.]+)\.\.\.v\d+})).is_a?(MatchData)
369
+ m[1]
370
+ else
371
+ # Default to previous tag name if none found (unlikely to be correct, but better than empty)
372
+ "HEAD^"
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end
378
+
379
+ begin
380
+ if ARGV.include?("-h") || ARGV.include?("--help")
381
+ puts <<~USAGE
382
+ Usage: kettle-changelog
383
+
384
+ Generates a new CHANGELOG.md entry for the current version detected from lib/**/version.rb.
385
+ Moves entries from [Unreleased] into the new section, adds coverage and documentation stats,
386
+ and updates bottom link references to GitHub style, adding new compare/tag links.
387
+
388
+ Prerequisites:
389
+ - coverage/coverage.json present (run: K_SOUP_COV_FORMATTERS="json" bin/rspec)
390
+ - yard installed and available via bin/yard
391
+ USAGE
392
+ exit(0)
393
+ end
394
+ Kettle::Dev::ChangelogCLI.new.run
395
+ rescue SystemExit
396
+ raise
397
+ rescue StandardError => e
398
+ warn("kettle-changelog: unexpected error: #{e.class}: #{e.message}")
399
+ warn(e.backtrace.join("\n")) if ENV["DEBUG"]
400
+ exit(1)
401
+ end
@@ -1,16 +1,20 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
2
4
  # vim: set syntax=ruby
3
5
 
4
- require "rubygems"
5
- begin
6
- require "bundler/setup"
7
- rescue LoadError
8
- # Allow running outside of Bundler; runtime deps should still be available via rubygems
9
- end
6
+ # Immediate, unbuffered output
7
+ $stdout.sync = true
8
+ # Depending library or project must be using bundler
9
+ require "bundler/setup"
10
10
 
11
11
  # Standard library
12
12
  require "erb"
13
13
 
14
+ require "kettle/dev"
15
+
16
+ puts "== kettle-commit-msg v#{Kettle::Dev::Version::VERSION} =="
17
+
14
18
  # ENV variable control (set in .envrc, or .env.local)
15
19
  # BRANCH_RULE_TYPE = jira, or another type of branch rule validation, or false to disable
16
20
  # FOOTER_APPEND = true/false append commit message footer
@@ -48,140 +52,4 @@ else
48
52
  # puts "No branch rule configured (set GIT_HOOK_BRANCH_VALIDATE=jira to enforce rules for jira style branch names)"
49
53
  end
50
54
 
51
- class GitCommitFooter
52
- # Prefer project-local .git-hooks (repo root), then fallback to global ~/.git-hooks
53
- NAME_ASSIGNMENT_REGEX = /\bname\s*=\s*(["'])([^"']+)\1/.freeze
54
- FOOTER_APPEND = ENV.fetch("GIT_HOOK_FOOTER_APPEND", "false").casecmp("true").zero?
55
- SENTINEL = ENV["GIT_HOOK_FOOTER_SENTINEL"] # No default to avoid accidental duplicate commit of a footer via ammended commits
56
- raise "Set GIT_HOOK_FOOTER_SENTINEL=<footer sentinel> in .env.local (e.g., '⚡️ A message from a fellow meat-based-AI ⚡️')" if FOOTER_APPEND && (SENTINEL.nil? || SENTINEL.to_s.empty?)
57
-
58
- class << self
59
- def git_toplevel
60
- toplevel = nil
61
- begin
62
- # 'git rev-parse --show-toplevel' returns the repo root when run anywhere inside the repo
63
- out = %x(git rev-parse --show-toplevel 2>/dev/null)
64
- toplevel = out.strip unless out.nil? || out.empty?
65
- rescue StandardError
66
- # ignore
67
- end
68
- toplevel
69
- end
70
-
71
- def local_hooks_dir
72
- top = git_toplevel
73
- return unless top && !top.empty?
74
- File.join(top, ".git-hooks")
75
- end
76
-
77
- def global_hooks_dir
78
- File.join(ENV["HOME"], ".git-hooks")
79
- end
80
-
81
- def hooks_path_for(filename)
82
- local_dir = local_hooks_dir
83
- if local_dir
84
- local_path = File.join(local_dir, filename)
85
- return local_path if File.file?(local_path)
86
- end
87
- File.join(global_hooks_dir, filename)
88
- end
89
-
90
- def commit_goalie_path
91
- hooks_path_for("commit-subjects-goalie.txt")
92
- end
93
-
94
- # Determine whether the commit subject allows footer append, based on optional goalie file
95
- # ~/.git-hooks/commit-subjects-goalie.txt
96
- # - If present, only allow appending when the first line of the commit message starts with one of the non-commented prefixes
97
- # - If absent, disallow footer
98
- def goalie_allows_footer?(subject_line)
99
- goalie_path = commit_goalie_path
100
- return false unless File.file?(goalie_path)
101
-
102
- prefixes = File.read(goalie_path).lines.map { |l| l.strip }.reject { |l| l.empty? || l.start_with?("#") }
103
- # If the file exists but has no usable lines, treat as deny-all per goalie intent
104
- return false if prefixes.empty?
105
-
106
- subj = subject_line.to_s.strip
107
- prefixes.any? { |prefix| subj.start_with?(prefix) }
108
- end
109
-
110
- def render(*argv)
111
- commit_msg = File.read(argv[0])
112
- subject_line = commit_msg.lines.first.to_s
113
- if GitCommitFooter::FOOTER_APPEND && goalie_allows_footer?(subject_line)
114
- if commit_msg.include?(GitCommitFooter::SENTINEL)
115
- # This is a commit message that has already been appended
116
- # This will happen if the commit message is edited and re-committed
117
- # puts "FOOTER_APPEND is true, skipping footer append"
118
- exit(0)
119
- else
120
- footer_binding = GitCommitFooter.new
121
- # Append footer to the commit message
122
- File.open(argv[0], "w") do |file|
123
- file.print(commit_msg)
124
- file.print("\n")
125
- file.print(footer_binding.render)
126
- end
127
- end
128
- else
129
- # Skipping footer append (either FOOTER_APPEND is false, or goalie did not allow it)
130
- end
131
- end
132
- end
133
-
134
- def initialize
135
- @pwd = Dir.pwd
136
- @gemspecs = Dir["*.gemspec"]
137
- @spec = @gemspecs.first
138
- @gemspec_path = File.expand_path(@spec, @pwd)
139
- @gem_name = parse_gemspec_name || derive_gem_name
140
- end
141
-
142
- # Render ERB with binding variables
143
- def render
144
- ERB.new(template).result(binding)
145
- end
146
-
147
- private
148
-
149
- # Lightweight parse for gem name to avoid full Gem::Specification load
150
- def parse_gemspec_name
151
- begin
152
- content = File.read(@gemspec_path)
153
- # Look for name assignment patterns like:
154
- # spec.name = "my_gem" OR Gem::Specification.new do |spec|; spec.name = 'my_gem'
155
- @name_index = content =~ NAME_ASSIGNMENT_REGEX
156
- if @name_index
157
- return $2
158
- end
159
- rescue StandardError
160
- # fall through
161
- end
162
- nil
163
- end
164
-
165
- # No-parse derivation of gem name, when parsing gemspec fails
166
- def derive_gem_name
167
- File.basename(@gemspec_path, ".*") if @gemspec_path
168
- end
169
-
170
- # Example
171
- #
172
- # ⚡️ A message from a fellow meat-based-AI ⚡️
173
- # I ❤️ working on <%= @gem_name %>.
174
- #
175
- # The first line is the footer sentinel (which does appear in the commit).
176
- # The second line, and any additional, is the main body of the footer.
177
- #
178
- # The sentinel must be set in an ENV variable (e.g., in your .env.local file):
179
- #
180
- # export GIT_HOOK_FOOTER_SENTINEL="⚡️ A message from a fellow meat-based-AI ⚡️"
181
- #
182
- def template
183
- File.read(self.class.hooks_path_for("footer-template.erb.txt"))
184
- end
185
- end
186
-
187
- GitCommitFooter.render(*ARGV)
55
+ Kettle::Dev::GitCommitFooter.render(*ARGV)