kettle-jem 7.0.0 → 7.1.3

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 (202) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +1505 -0
  4. data/LICENSE.md +13 -0
  5. data/README.md +794 -0
  6. data/exe/kettle-jem +20 -0
  7. data/lib/kettle/jem/cli.rb +334 -0
  8. data/lib/kettle/jem/license_txt_migrator.rb +33 -0
  9. data/lib/kettle/jem/rakelib/install.rake +10 -0
  10. data/lib/kettle/jem/rakelib/prepare.rake +10 -0
  11. data/lib/kettle/jem/rakelib/selftest.rake +10 -0
  12. data/lib/kettle/jem/rakelib/template.rake +10 -0
  13. data/lib/kettle/jem/tasks/install_task.rb +2178 -0
  14. data/lib/kettle/jem/tasks/prepare_task.rb +274 -0
  15. data/lib/kettle/jem/tasks/self_test_task.rb +256 -0
  16. data/lib/kettle/jem/tasks/template_task.rb +124 -0
  17. data/lib/kettle/jem/tasks.rb +6 -0
  18. data/lib/kettle/jem/templates/.aiignore.example +19 -0
  19. data/lib/kettle/jem/templates/.config/mise/env.sh +26 -0
  20. data/lib/kettle/jem/templates/.devcontainer/apt-install/devcontainer-feature.json.example +9 -0
  21. data/lib/kettle/jem/templates/.devcontainer/apt-install/install.sh.example +19 -0
  22. data/lib/kettle/jem/templates/.devcontainer/devcontainer.json.example +27 -0
  23. data/lib/kettle/jem/templates/.devcontainer/scripts/setup-tree-sitter.sh.example +210 -0
  24. data/lib/kettle/jem/templates/.env.local.example +67 -0
  25. data/lib/kettle/jem/templates/.envrc.example +4 -0
  26. data/lib/kettle/jem/templates/.envrc.no-osc.example +4 -0
  27. data/lib/kettle/jem/templates/.gemrc.example +1 -0
  28. data/lib/kettle/jem/templates/.git-hooks/commit-msg.example +60 -0
  29. data/lib/kettle/jem/templates/.git-hooks/commit-subjects-goalie.txt.example +8 -0
  30. data/lib/kettle/jem/templates/.git-hooks/footer-template.erb.txt.example +16 -0
  31. data/lib/kettle/jem/templates/.git-hooks/prepare-commit-msg.example +9 -0
  32. data/lib/kettle/jem/templates/.github/.codecov.yml.example +14 -0
  33. data/lib/kettle/jem/templates/.github/FUNDING.yml.example +10 -0
  34. data/lib/kettle/jem/templates/.github/FUNDING.yml.no-osc.example +10 -0
  35. data/lib/kettle/jem/templates/.github/copilot_instructions.md.example +1 -0
  36. data/lib/kettle/jem/templates/.github/dependabot.yml.example +13 -0
  37. data/lib/kettle/jem/templates/.github/workflows/auto-assign.yml.example +21 -0
  38. data/lib/kettle/jem/templates/.github/workflows/coverage.yml.example +102 -0
  39. data/lib/kettle/jem/templates/.github/workflows/current.yml.example +86 -0
  40. data/lib/kettle/jem/templates/.github/workflows/dep-heads.yml.example +249 -0
  41. data/lib/kettle/jem/templates/.github/workflows/dependency-review.yml.example +22 -0
  42. data/lib/kettle/jem/templates/.github/workflows/discord-notifier.yml.example +39 -0
  43. data/lib/kettle/jem/templates/.github/workflows/framework-ci.yml.example +81 -0
  44. data/lib/kettle/jem/templates/.github/workflows/heads.yml.example +212 -0
  45. data/lib/kettle/jem/templates/.github/workflows/jruby-10.0.yml.example +91 -0
  46. data/lib/kettle/jem/templates/.github/workflows/jruby-9.1.yml.example +90 -0
  47. data/lib/kettle/jem/templates/.github/workflows/jruby-9.2.yml.example +95 -0
  48. data/lib/kettle/jem/templates/.github/workflows/jruby-9.3.yml.example +95 -0
  49. data/lib/kettle/jem/templates/.github/workflows/jruby-9.4.yml.example +95 -0
  50. data/lib/kettle/jem/templates/.github/workflows/jruby.yml.example +91 -0
  51. data/lib/kettle/jem/templates/.github/workflows/license-eye.yml.example +43 -0
  52. data/lib/kettle/jem/templates/.github/workflows/locked_deps.yml.example +89 -0
  53. data/lib/kettle/jem/templates/.github/workflows/opencollective.yml.example +44 -0
  54. data/lib/kettle/jem/templates/.github/workflows/ruby-2.3.yml.example +80 -0
  55. data/lib/kettle/jem/templates/.github/workflows/ruby-2.4.yml.example +84 -0
  56. data/lib/kettle/jem/templates/.github/workflows/ruby-2.5.yml.example +84 -0
  57. data/lib/kettle/jem/templates/.github/workflows/ruby-2.6.yml.example +84 -0
  58. data/lib/kettle/jem/templates/.github/workflows/ruby-2.7.yml.example +84 -0
  59. data/lib/kettle/jem/templates/.github/workflows/ruby-3.0.yml.example +85 -0
  60. data/lib/kettle/jem/templates/.github/workflows/ruby-3.1.yml.example +83 -0
  61. data/lib/kettle/jem/templates/.github/workflows/ruby-3.2.yml.example +80 -0
  62. data/lib/kettle/jem/templates/.github/workflows/ruby-3.3.yml.example +80 -0
  63. data/lib/kettle/jem/templates/.github/workflows/ruby-3.4.yml.example +80 -0
  64. data/lib/kettle/jem/templates/.github/workflows/style.yml.example +78 -0
  65. data/lib/kettle/jem/templates/.github/workflows/templating.yml.example +86 -0
  66. data/lib/kettle/jem/templates/.github/workflows/truffle.yml.example +91 -0
  67. data/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example +92 -0
  68. data/lib/kettle/jem/templates/.github/workflows/truffleruby-23.0.yml.example +92 -0
  69. data/lib/kettle/jem/templates/.github/workflows/truffleruby-23.1.yml.example +78 -0
  70. data/lib/kettle/jem/templates/.github/workflows/truffleruby-24.2.yml.example +91 -0
  71. data/lib/kettle/jem/templates/.github/workflows/truffleruby-25.0.yml.example +96 -0
  72. data/lib/kettle/jem/templates/.github/workflows/truffleruby-33.0.yml.example +91 -0
  73. data/lib/kettle/jem/templates/.github/workflows/unlocked_deps.yml.example +93 -0
  74. data/lib/kettle/jem/templates/.gitignore.example +53 -0
  75. data/lib/kettle/jem/templates/.gitlab-ci.yml.example +134 -0
  76. data/lib/kettle/jem/templates/.idea/.gitignore.example +44 -0
  77. data/lib/kettle/jem/templates/.licenserc.yaml.example +8 -0
  78. data/lib/kettle/jem/templates/.opencollective.yml.example +3 -0
  79. data/lib/kettle/jem/templates/.qlty/qlty.toml.example +79 -0
  80. data/lib/kettle/jem/templates/.rspec.example +10 -0
  81. data/lib/kettle/jem/templates/.rubocop.example +1 -0
  82. data/lib/kettle/jem/templates/.rubocop.yml.example +47 -0
  83. data/lib/kettle/jem/templates/.rubocop_rspec.yml.example +55 -0
  84. data/lib/kettle/jem/templates/.simplecov.example +18 -0
  85. data/lib/kettle/jem/templates/.structuredmerge/git-drivers.toml.example +86 -0
  86. data/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example +519 -0
  87. data/lib/kettle/jem/templates/.yard-lint.yml.example +54 -0
  88. data/lib/kettle/jem/templates/.yardignore.example +13 -0
  89. data/lib/kettle/jem/templates/.yardopts.example +14 -0
  90. data/lib/kettle/jem/templates/AGENTS.md.example +283 -0
  91. data/lib/kettle/jem/templates/AGPL-3.0-only.md.example +235 -0
  92. data/lib/kettle/jem/templates/Appraisal.root.gemfile.example +26 -0
  93. data/lib/kettle/jem/templates/Appraisals.example +150 -0
  94. data/lib/kettle/jem/templates/Big-Time-Public-License.md.example +99 -0
  95. data/lib/kettle/jem/templates/CHANGELOG.md.example +33 -0
  96. data/lib/kettle/jem/templates/CHANGELOG.transfer.md +109 -0
  97. data/lib/kettle/jem/templates/CITATION.cff.example +20 -0
  98. data/lib/kettle/jem/templates/CODE_OF_CONDUCT.md.example +134 -0
  99. data/lib/kettle/jem/templates/CONTRIBUTING.md.example +292 -0
  100. data/lib/kettle/jem/templates/FUNDING.md.example +74 -0
  101. data/lib/kettle/jem/templates/FUNDING.md.no-osc.example +63 -0
  102. data/lib/kettle/jem/templates/Gemfile.example +52 -0
  103. data/lib/kettle/jem/templates/IRP.md.example +34 -0
  104. data/lib/kettle/jem/templates/LICENSE.md.example +3 -0
  105. data/lib/kettle/jem/templates/MIT.md.example +21 -0
  106. data/lib/kettle/jem/templates/PolyForm-Noncommercial-1.0.0.md.example +131 -0
  107. data/lib/kettle/jem/templates/PolyForm-Small-Business-1.0.0.md.example +121 -0
  108. data/lib/kettle/jem/templates/README.md.example +544 -0
  109. data/lib/kettle/jem/templates/REEK +0 -0
  110. data/lib/kettle/jem/templates/RUBOCOP.md.example +78 -0
  111. data/lib/kettle/jem/templates/Rakefile.example +163 -0
  112. data/lib/kettle/jem/templates/SECURITY.md.example +21 -0
  113. data/lib/kettle/jem/templates/bin/setup.example +36 -0
  114. data/lib/kettle/jem/templates/certs/pboling.pem.example +27 -0
  115. data/lib/kettle/jem/templates/gem.gemspec.example +161 -0
  116. data/lib/kettle/jem/templates/gemfiles/modular/benchmark/r4/v0.5.gemfile.example +2 -0
  117. data/lib/kettle/jem/templates/gemfiles/modular/benchmark/vHEAD.gemfile.example +2 -0
  118. data/lib/kettle/jem/templates/gemfiles/modular/cgi/r4/v0.5.gemfile.example +1 -0
  119. data/lib/kettle/jem/templates/gemfiles/modular/cgi/vHEAD.gemfile.example +1 -0
  120. data/lib/kettle/jem/templates/gemfiles/modular/changelog.gemfile.example +6 -0
  121. data/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example +14 -0
  122. data/lib/kettle/jem/templates/gemfiles/modular/coverage_local.gemfile.example +34 -0
  123. data/lib/kettle/jem/templates/gemfiles/modular/debug.gemfile.example +22 -0
  124. data/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example +24 -0
  125. data/lib/kettle/jem/templates/gemfiles/modular/documentation_local.gemfile.example +20 -0
  126. data/lib/kettle/jem/templates/gemfiles/modular/erb/r2/v3.0.gemfile.example +1 -0
  127. data/lib/kettle/jem/templates/gemfiles/modular/erb/r2.3/default.gemfile.example +6 -0
  128. data/lib/kettle/jem/templates/gemfiles/modular/erb/r2.6/v2.2.gemfile.example +3 -0
  129. data/lib/kettle/jem/templates/gemfiles/modular/erb/r3/v5.0.gemfile.example +1 -0
  130. data/lib/kettle/jem/templates/gemfiles/modular/erb/r3.1/v4.0.gemfile.example +2 -0
  131. data/lib/kettle/jem/templates/gemfiles/modular/erb/r4/v5.0.gemfile.example +1 -0
  132. data/lib/kettle/jem/templates/gemfiles/modular/erb/vHEAD.gemfile.example +2 -0
  133. data/lib/kettle/jem/templates/gemfiles/modular/json/truffleruby_22_3.gemfile.example +6 -0
  134. data/lib/kettle/jem/templates/gemfiles/modular/json/truffleruby_23_0.gemfile.example +6 -0
  135. data/lib/kettle/jem/templates/gemfiles/modular/json/truffleruby_23_1.gemfile.example +6 -0
  136. data/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2/v0.3.gemfile.example +2 -0
  137. data/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r2.4/v0.1.gemfile.example +3 -0
  138. data/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r3/v0.3.gemfile.example +2 -0
  139. data/lib/kettle/jem/templates/gemfiles/modular/mutex_m/r4/v0.3.gemfile.example +2 -0
  140. data/lib/kettle/jem/templates/gemfiles/modular/mutex_m/vHEAD.gemfile.example +2 -0
  141. data/lib/kettle/jem/templates/gemfiles/modular/optional.gemfile.example +16 -0
  142. data/lib/kettle/jem/templates/gemfiles/modular/recording/r2.3/recording.gemfile.example +16 -0
  143. data/lib/kettle/jem/templates/gemfiles/modular/recording/r2.4/recording.gemfile.example +18 -0
  144. data/lib/kettle/jem/templates/gemfiles/modular/recording/r2.5/recording.gemfile.example +16 -0
  145. data/lib/kettle/jem/templates/gemfiles/modular/recording/r3/recording.gemfile.example +16 -0
  146. data/lib/kettle/jem/templates/gemfiles/modular/recording/r4/recording.gemfile.example +16 -0
  147. data/lib/kettle/jem/templates/gemfiles/modular/recording/vHEAD.gemfile.example +2 -0
  148. data/lib/kettle/jem/templates/gemfiles/modular/rspec.gemfile.example +4 -0
  149. data/lib/kettle/jem/templates/gemfiles/modular/runtime_heads.gemfile.example +14 -0
  150. data/lib/kettle/jem/templates/gemfiles/modular/shunted.gemfile.example +21 -0
  151. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r2/v3.0.gemfile.example +5 -0
  152. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r2.4/v0.0.2.gemfile.example +4 -0
  153. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r3/v3.0.gemfile.example +5 -0
  154. data/lib/kettle/jem/templates/gemfiles/modular/stringio/r4/v3.0.gemfile.example +5 -0
  155. data/lib/kettle/jem/templates/gemfiles/modular/stringio/vHEAD.gemfile.example +2 -0
  156. data/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example +33 -0
  157. data/lib/kettle/jem/templates/gemfiles/modular/style_local.gemfile.example +26 -0
  158. data/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example +34 -0
  159. data/lib/kettle/jem/templates/gemfiles/modular/templating_local.gemfile.example +73 -0
  160. data/lib/kettle/jem/templates/gemfiles/modular/webrick/r4/v1.9.gemfile.example +1 -0
  161. data/lib/kettle/jem/templates/gemfiles/modular/webrick/vHEAD.gemfile.example +1 -0
  162. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2/libs.gemfile.example +3 -0
  163. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.3/libs.gemfile.example +3 -0
  164. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.4/libs.gemfile.example +3 -0
  165. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r2.6/libs.gemfile.example +3 -0
  166. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3/libs.gemfile.example +3 -0
  167. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r3.1/libs.gemfile.example +5 -0
  168. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r4/libs.gemfile.example +6 -0
  169. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/vHEAD.gemfile.example +6 -0
  170. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs.gemfile.example +12 -0
  171. data/lib/kettle/jem/templates/lib/gem/version.rb.example +1 -0
  172. data/lib/kettle/jem/templates/lib/gem/version_gem.rb.example +1 -0
  173. data/lib/kettle/jem/templates/mise.toml.example +25 -0
  174. data/lib/kettle/jem/templates/mise.toml.no-osc.example +25 -0
  175. data/lib/kettle/jem/templates/parsers.toml.example +19 -0
  176. data/lib/kettle/jem/templates/readme/partials/basic_usage.md.example +28 -0
  177. data/lib/kettle/jem/templates/readme/partials/configuration.md.example +112 -0
  178. data/lib/kettle/jem/templates/readme/partials/synopsis.md.example +3 -0
  179. data/lib/kettle/jem/templates/shim/.github/workflows/current.yml.example +40 -0
  180. data/lib/kettle/jem/templates/shim/.rspec.example +5 -0
  181. data/lib/kettle/jem/templates/shim/.structuredmerge/kettle-jem.yml.example +28 -0
  182. data/lib/kettle/jem/templates/shim/CHANGELOG.md.example +9 -0
  183. data/lib/kettle/jem/templates/shim/Gemfile.example +14 -0
  184. data/lib/kettle/jem/templates/shim/README.md.example +25 -0
  185. data/lib/kettle/jem/templates/shim/Rakefile.example +8 -0
  186. data/lib/kettle/jem/templates/shim/gem.gemspec.example +45 -0
  187. data/lib/kettle/jem/templates/shim/lib/compat_require.rb.example +3 -0
  188. data/lib/kettle/jem/templates/shim/lib/entrypoint.rb.example +3 -0
  189. data/lib/kettle/jem/templates/shim/lib/version.rb.example +1 -0
  190. data/lib/kettle/jem/templates/shim/spec/shim_spec.rb.example +15 -0
  191. data/lib/kettle/jem/templates/shim/spec/spec_helper.rb.example +23 -0
  192. data/lib/kettle/jem/templates/sig/gem.rbs.example +1 -0
  193. data/lib/kettle/jem/templates/spec/README.md.example +28 -0
  194. data/lib/kettle/jem/templates/spec/spec_helper.rb.example +39 -0
  195. data/lib/kettle/jem/version.rb +5 -3
  196. data/lib/kettle/jem/version_gem.rb +8 -0
  197. data/lib/kettle/jem.rb +18540 -1517
  198. data/lib/kettle-jem.rb +2 -1
  199. data/sig/kettle/jem.rbs +8 -0
  200. data.tar.gz.sig +0 -0
  201. metadata +870 -32
  202. metadata.gz.sig +0 -0
@@ -0,0 +1,2178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "bundler"
5
+ require "json"
6
+ require "open3"
7
+ require "toml-rb"
8
+ require "uri"
9
+ require "yaml"
10
+ require "kettle/rb/compat_matrix"
11
+
12
+ module Kettle
13
+ module Jem
14
+ module Tasks
15
+ module InstallTask
16
+ module_function
17
+
18
+ CURATED_BINSTUB_GEMS = %w[appraisal2 rake rbs rspec-core yard kettle-dev kettle-test kettle-soup-cover kettle-gha-pins stone_checksums].freeze
19
+ CURATED_BINSTUB_DISCRETE_EXECUTABLES = %w[
20
+ kettle-drift
21
+ yard
22
+ ].freeze
23
+ GIT_OPERATION_LOCK_ENV_KEYS = %w[KETTLE_JEM_GIT_LOCK KETTLE_JEM_GIT_COMMIT_LOCK].freeze
24
+ GIT_OPERATION_LOCK_RETRY_ATTEMPTS = 5
25
+ GIT_OPERATION_LOCK_RETRY_SLEEP_SECONDS = 0.25
26
+
27
+ def run(project_root: Dir.pwd, env: ENV, run_options: {}, command_runner: method(:run_system_command))
28
+ effective_run_options = install_run_options(env, run_options)
29
+ config_migration_step = kettle_config_migration_step(project_root)
30
+ report = Kettle::Jem.apply_project(project_root, env: env, run_options: effective_run_options)
31
+ report = followup_apply_after_config_bootstrap(project_root, env: env, run_options: effective_run_options, report: report)
32
+ install_steps = []
33
+ install_steps << config_migration_step if config_migration_step
34
+ install_steps << gemspec_dependency_sync_step(report)
35
+ install_steps.concat(version_gem_bootstrap_steps(project_root, report))
36
+ mise_step = mise_trust_step(project_root, report, env: env)
37
+ install_steps << mise_step if mise_step
38
+ install_steps.concat(post_template_project_fix_steps(project_root, report, env: env))
39
+ install_steps << hook_templates_step(project_root, effective_run_options)
40
+ install_steps << git_drivers_step(project_root, effective_run_options)
41
+ install_steps << ensure_bin_setup_executable(project_root)
42
+ setup_env = setup_command_env(project_root, env)
43
+ rubocop_lts_branch_step = rubocop_lts_local_branch_step(report, env: setup_env, project_root: project_root)
44
+ install_steps << rubocop_lts_branch_step if rubocop_lts_branch_step
45
+ install_steps << run_distinct_bundle_install_step(project_root, env: env, setup_env: setup_env, run_options: effective_run_options, command_runner: command_runner)
46
+ install_steps.concat(run_bundle_setup_commands(project_root, env: setup_env, run_options: effective_run_options, command_runner: command_runner))
47
+ install_steps << appraisal_generate_step(project_root, env: setup_env, run_options: effective_run_options)
48
+ install_steps << rubocop_gradual_autocorrect_step(project_root, env: setup_env, run_options: effective_run_options)
49
+ install_steps << normalize_lockfile_step(project_root, env: setup_env, run_options: effective_run_options)
50
+ install_steps << bundled_handoff_step(project_root: project_root, env: env, run_options: effective_run_options)
51
+ install_steps << bootstrap_commit_step(project_root, run_options: effective_run_options)
52
+ install_steps = execute_orchestration_steps(install_steps, project_root: project_root, env: setup_env, run_options: effective_run_options, command_runner: command_runner)
53
+
54
+ final_report = report.merge(
55
+ mode: "install",
56
+ installed: true,
57
+ install_steps: install_steps,
58
+ install_phase_reports: install_phase_reports(install_steps),
59
+ install_summary: install_step_summary(install_steps),
60
+ diagnostics: report.fetch(:diagnostics) + [{
61
+ severity: "advisory",
62
+ message: "kettle:jem:install applied templates, completed local post-template checks, and executed available orchestration steps."
63
+ }]
64
+ )
65
+ Kettle::Jem.emit_summary_event(Kettle::Jem.event_stream_from_options(effective_run_options), final_report)
66
+ final_report
67
+ end
68
+
69
+ def install_run_options(env, run_options)
70
+ Kettle::Jem::Tasks::TemplateTask.templating_run_options(env || {}, run_options || {})
71
+ end
72
+
73
+ def followup_apply_after_config_bootstrap(project_root, env:, run_options:, report:)
74
+ return report unless config_bootstrap_changed?(report)
75
+
76
+ followup = Kettle::Jem.apply_project(project_root, env: env, run_options: run_options)
77
+ merge_apply_reports(report, followup).merge(
78
+ bootstrap_followup_apply: {
79
+ status: "applied",
80
+ reason: "canonical_config_bootstrapped"
81
+ }
82
+ )
83
+ end
84
+
85
+ def config_bootstrap_changed?(report)
86
+ report.fetch(:recipe_reports, []).any? do |recipe_report|
87
+ recipe_report.fetch(:relative_path, nil) == Kettle::Jem::KETTLE_CONFIG_PATH && recipe_report.fetch(:changed, false)
88
+ end
89
+ end
90
+
91
+ def merge_apply_reports(initial, followup)
92
+ merged = followup.merge(
93
+ changed_files: (initial.fetch(:changed_files, []) + followup.fetch(:changed_files, [])).uniq,
94
+ recipe_reports: initial.fetch(:recipe_reports, []) + followup.fetch(:recipe_reports, []),
95
+ post_apply_steps: initial.fetch(:post_apply_steps, []) + followup.fetch(:post_apply_steps, []),
96
+ diagnostics: initial.fetch(:diagnostics, []) + followup.fetch(:diagnostics, [])
97
+ )
98
+ merged[:changed] = initial.fetch(:changed, false) || followup.fetch(:changed, false) if initial.key?(:changed) || followup.key?(:changed)
99
+ merged
100
+ end
101
+
102
+ def kettle_config_migration_step(project_root)
103
+ canonical_relative = Kettle::Jem::KETTLE_CONFIG_PATH
104
+ legacy_relative = Kettle::Jem::LEGACY_KETTLE_CONFIG_PATH
105
+ canonical = File.join(project_root.to_s, canonical_relative)
106
+ legacy = File.join(project_root.to_s, legacy_relative)
107
+ return nil unless File.exist?(legacy)
108
+
109
+ if File.exist?(canonical)
110
+ return {
111
+ name: "kettle_config_migration",
112
+ status: "blocked",
113
+ reason: "legacy_kettle_config_conflict",
114
+ canonical_path: canonical_relative,
115
+ legacy_path: legacy_relative,
116
+ diagnostics: [{
117
+ key: "legacy_kettle_config_conflict",
118
+ severity: "warning",
119
+ blocking: false,
120
+ path: legacy_relative,
121
+ canonical_path: canonical_relative
122
+ }]
123
+ }
124
+ end
125
+
126
+ FileUtils.mkdir_p(File.dirname(canonical))
127
+ FileUtils.mv(legacy, canonical)
128
+ {
129
+ name: "kettle_config_migration",
130
+ status: "migrated",
131
+ reason: "legacy_kettle_config_migrated",
132
+ canonical_path: canonical_relative,
133
+ legacy_path: legacy_relative,
134
+ changed_files: [legacy_relative, canonical_relative]
135
+ }
136
+ end
137
+
138
+ def gemspec_dependency_sync_step(report)
139
+ gemspec_report = report.fetch(:recipe_reports, []).find do |recipe_report|
140
+ recipe_report.fetch(:relative_path, "").end_with?(".gemspec")
141
+ end
142
+ unless gemspec_report
143
+ return {
144
+ name: "gemspec_dependency_sync",
145
+ status: "unavailable",
146
+ reason: "no_gemspec_recipe"
147
+ }
148
+ end
149
+
150
+ {
151
+ name: "gemspec_dependency_sync",
152
+ path: gemspec_report.fetch(:relative_path),
153
+ status: gemspec_report.fetch(:changed, false) ? "applied" : "already_current",
154
+ development_dependencies: development_dependency_names(gemspec_report.fetch(:final_content, ""))
155
+ }
156
+ end
157
+
158
+ def development_dependency_names(content)
159
+ Kettle::Jem.gemspec_dependency_records(content)
160
+ .select { |dependency| dependency.fetch(:kind) == "add_development_dependency" }
161
+ .map { |dependency| dependency.fetch(:name) }
162
+ .uniq
163
+ end
164
+
165
+ def version_gem_bootstrap_steps(project_root, report)
166
+ existing = report.fetch(:post_apply_steps, []).select do |step|
167
+ %w[version_gem_bootstrap version_gem_cleanup legacy_rbs_consolidation].include?(step.fetch(:name, nil))
168
+ end
169
+ return existing unless existing.empty?
170
+
171
+ [Kettle::Jem.template_version_gem_bootstrap_step(project_root, report)].flatten.compact
172
+ end
173
+
174
+ def install_phase_reports(install_steps)
175
+ phases = {
176
+ "template_apply" => %w[gemspec_dependency_sync],
177
+ "post_template" => %w[
178
+ kettle_config_migration
179
+ version_gem_bootstrap
180
+ version_gem_cleanup
181
+ legacy_rbs_consolidation
182
+ mise_trust
183
+ legacy_ruby_version_file_cleanup
184
+ readme_compatibility_badges
185
+ readme_gemspec_grapheme_sync
186
+ gemspec_homepage_literal
187
+ env_local_gitignore
188
+ hook_templates
189
+ git_drivers
190
+ rubocop_lts_local_branch
191
+ bundle_install_requested_env
192
+ appraisal_generate
193
+ bin_setup_executable
194
+ bin_setup
195
+ bundle_binstubs
196
+ curated_binstubs_executable
197
+ bundle_binstub_pruning
198
+ bundle_binstub_location_validation
199
+ rubocop_gradual_autocorrect
200
+ bundle_lock_normalization
201
+ ],
202
+ "orchestration" => %w[bundled_handoff bootstrap_commit]
203
+ }
204
+ phases.map do |phase, names|
205
+ steps = install_steps.select { |step| names.include?(step.fetch(:name).to_s) }
206
+ {
207
+ phase: phase,
208
+ steps: steps.map { |step| step.fetch(:name) },
209
+ statuses: steps.to_h { |step| [step.fetch(:name), step.fetch(:status)] }
210
+ }
211
+ end
212
+ end
213
+
214
+ def install_step_summary(install_steps)
215
+ statuses = install_steps.each_with_object(Hash.new(0)) do |step, counts|
216
+ counts[step.fetch(:status, "unknown").to_s] += 1
217
+ end.sort.to_h
218
+ {
219
+ steps: install_steps.length,
220
+ statuses: statuses,
221
+ summary: "install steps #{install_steps.length}; #{statuses.map { |status, count| "#{status} #{count}" }.join("; ")}"
222
+ }
223
+ end
224
+
225
+ def ensure_bin_setup_executable(project_root)
226
+ path = File.join(project_root, "bin", "setup")
227
+ return {name: "bin_setup_executable", path: "bin/setup", status: "missing"} unless File.exist?(path)
228
+
229
+ before = File.stat(path).mode
230
+ FileUtils.chmod(before | 0o111, path)
231
+ after = File.stat(path).mode
232
+ {
233
+ name: "bin_setup_executable",
234
+ path: "bin/setup",
235
+ status: ((before == after) ? "already_executable" : "updated")
236
+ }
237
+ end
238
+
239
+ def mise_trust_step(project_root, report, env:)
240
+ mise_report = report.fetch(:recipe_reports, []).find do |recipe_report|
241
+ recipe_report.fetch(:relative_path, "") == "mise.toml"
242
+ end
243
+ return nil unless mise_report&.fetch(:changed, false)
244
+
245
+ command = ["mise", "trust", "-C", project_root.to_s]
246
+ if mise_installed?(env)
247
+ return {
248
+ name: "mise_trust",
249
+ path: "mise.toml",
250
+ command: command,
251
+ status: "ready",
252
+ reason: "mise_toml_changed"
253
+ }
254
+ end
255
+
256
+ {
257
+ name: "mise_trust",
258
+ path: "mise.toml",
259
+ command: command,
260
+ status: "unavailable",
261
+ reason: "mise_not_installed",
262
+ install_url: "https://mise.jdx.dev/getting-started.html"
263
+ }
264
+ end
265
+
266
+ def mise_installed?(env)
267
+ path = (env || {})["PATH"].to_s
268
+ path = ENV["PATH"].to_s if path.empty?
269
+ path.split(File::PATH_SEPARATOR).any? do |dir|
270
+ candidate = File.join(dir, "mise")
271
+ File.file?(candidate) && File.executable?(candidate)
272
+ end
273
+ end
274
+
275
+ def post_template_project_fix_steps(project_root, report, env:)
276
+ [
277
+ cleanup_legacy_ruby_version_files(project_root),
278
+ trim_readme_compatibility_badges(project_root, report),
279
+ sync_readme_gemspec_grapheme(project_root, env),
280
+ repair_gemspec_homepage(project_root, env),
281
+ ensure_env_local_gitignore(project_root)
282
+ ].compact
283
+ end
284
+
285
+ def cleanup_legacy_ruby_version_files(project_root)
286
+ return nil unless File.file?(File.join(project_root.to_s, "mise.toml"))
287
+
288
+ removed = %w[.ruby-version .ruby-gemset .tool-versions].filter_map do |relative_path|
289
+ path = File.join(project_root.to_s, relative_path)
290
+ next unless File.exist?(path)
291
+
292
+ FileUtils.rm_f(path)
293
+ relative_path
294
+ end
295
+ {
296
+ name: "legacy_ruby_version_file_cleanup",
297
+ status: removed.empty? ? "already_current" : "applied",
298
+ removed_files: removed
299
+ }
300
+ end
301
+
302
+ def trim_readme_compatibility_badges(project_root, report)
303
+ readme_path = File.join(project_root.to_s, "README.md")
304
+ return nil unless File.file?(readme_path)
305
+
306
+ min_ruby = report.dig(:facts, :rubygems, :min_ruby)
307
+ if min_ruby.to_s.empty?
308
+ return {
309
+ name: "readme_compatibility_badges",
310
+ status: "skipped",
311
+ reason: "missing_min_ruby"
312
+ }
313
+ end
314
+
315
+ before = File.read(readme_path)
316
+ after = Kettle::Jem::ReadmePostProcessor.process(
317
+ content: before,
318
+ min_ruby: Gem::Version.new(Kettle::Jem.minimum_ruby_token(min_ruby)),
319
+ engines: report.dig(:facts, :rubygems, :engines),
320
+ workflow_paths: github_workflow_paths(project_root)
321
+ )
322
+ File.write(readme_path, after) if after != before
323
+ {
324
+ name: "readme_compatibility_badges",
325
+ path: "README.md",
326
+ status: (after == before) ? "already_current" : "applied"
327
+ }
328
+ rescue => error
329
+ {
330
+ name: "readme_compatibility_badges",
331
+ path: "README.md",
332
+ status: "skipped",
333
+ reason: error.message
334
+ }
335
+ end
336
+
337
+ def github_workflow_paths(project_root)
338
+ workflow_root = File.join(project_root.to_s, ".github", "workflows")
339
+ return [] unless Dir.exist?(workflow_root)
340
+
341
+ Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).map do |path|
342
+ Pathname(path).relative_path_from(Pathname(project_root.to_s)).to_s
343
+ end.sort
344
+ end
345
+
346
+ def sync_readme_gemspec_grapheme(project_root, env)
347
+ readme_path = File.join(project_root.to_s, "README.md")
348
+ gemspec_path = Dir.glob(File.join(project_root.to_s, "*.gemspec")).min
349
+ return nil unless File.file?(readme_path) && gemspec_path
350
+
351
+ readme = File.read(readme_path)
352
+ gemspec = File.read(gemspec_path)
353
+ grapheme = configured_project_grapheme(project_root, env) || readme_h1_grapheme(readme)
354
+ if grapheme.to_s.empty?
355
+ return {
356
+ name: "readme_gemspec_grapheme_sync",
357
+ status: "skipped",
358
+ reason: "missing_grapheme"
359
+ }
360
+ end
361
+
362
+ updated_readme = normalize_readme_h1_grapheme(readme, grapheme)
363
+ updated_gemspec = normalize_gemspec_grapheme(gemspec, grapheme)
364
+ File.write(readme_path, updated_readme) if updated_readme != readme
365
+ File.write(gemspec_path, updated_gemspec) if updated_gemspec != gemspec
366
+ {
367
+ name: "readme_gemspec_grapheme_sync",
368
+ paths: ["README.md", File.basename(gemspec_path)],
369
+ status: (updated_readme == readme && updated_gemspec == gemspec) ? "already_current" : "applied",
370
+ grapheme: grapheme
371
+ }
372
+ rescue => error
373
+ {
374
+ name: "readme_gemspec_grapheme_sync",
375
+ status: "skipped",
376
+ reason: error.message
377
+ }
378
+ end
379
+
380
+ def configured_project_grapheme(project_root, env)
381
+ env_value = (env || {})["KJ_PROJECT_EMOJI"].to_s.strip
382
+ return first_grapheme(env_value) unless env_value.empty? || Kettle::Jem::DecisionPolicy.falsey?(env_value)
383
+
384
+ config_path = Kettle::Jem.kettle_jem_config_path(project_root.to_s)
385
+ return nil unless File.file?(config_path)
386
+
387
+ config = YAML.safe_load_file(config_path, permitted_classes: [], aliases: false)
388
+ value = config["project_emoji"].to_s.strip if config.is_a?(Hash)
389
+ value.to_s.empty? ? nil : first_grapheme(value)
390
+ rescue
391
+ nil
392
+ end
393
+
394
+ def readme_h1_grapheme(content)
395
+ h1 = Kettle::Jem.markdown_heading_owners(content, source_label: "README.md").find { |owner| owner.level == 1 }
396
+ return nil unless h1
397
+
398
+ first = first_grapheme(h1.heading_text)
399
+ decorative_grapheme?(first) ? first : nil
400
+ end
401
+
402
+ def normalize_readme_h1_grapheme(content, grapheme)
403
+ lines = content.to_s.split("\n", -1)
404
+ h1 = Kettle::Jem.markdown_heading_owners(content, source_label: "README.md").find { |owner| owner.level == 1 }
405
+ return content unless h1
406
+
407
+ index = h1.location.start_line - 1
408
+ rest = h1.heading_text
409
+ lines[index] = "# #{grapheme} #{strip_leading_decorative_graphemes(rest)}".rstrip
410
+ lines.join("\n")
411
+ end
412
+
413
+ def normalize_gemspec_grapheme(content, grapheme)
414
+ replacements = gemspec_grapheme_assignment_replacements(content, grapheme)
415
+ replace_character_ranges(content.to_s, replacements)
416
+ end
417
+
418
+ def gemspec_grapheme_assignment_replacements(content, grapheme)
419
+ Kettle::Jem.ruby_call_records(content, nil).filter_map do |call|
420
+ next unless %i[summary= description=].include?(call.name)
421
+ next unless call.receiver&.slice == "spec"
422
+
423
+ argument = call.arguments&.arguments&.first
424
+ next unless argument.is_a?(::Prism::StringNode) && argument.content_loc
425
+
426
+ replacement = "#{grapheme} #{strip_leading_decorative_graphemes(argument.unescaped)}"
427
+ [
428
+ argument.content_loc.start_character_offset,
429
+ argument.content_loc.end_character_offset,
430
+ ruby_string_literal_content(replacement, argument.opening_loc&.slice)
431
+ ]
432
+ end
433
+ end
434
+
435
+ def ruby_string_literal_content(value, quote)
436
+ escaped = value.to_s.gsub("\\", "\\\\\\\\")
437
+ quote.to_s.empty? ? escaped : escaped.gsub(quote.to_s, "\\#{quote}")
438
+ end
439
+
440
+ def replace_character_ranges(content, replacements)
441
+ replacements.sort_by(&:first).reverse.reduce(content.to_s) do |updated, (start_character_offset, end_character_offset, replacement)|
442
+ "#{updated[0...start_character_offset]}#{replacement}#{updated[end_character_offset..]}"
443
+ end
444
+ end
445
+
446
+ def strip_leading_decorative_graphemes(text)
447
+ remaining = text.to_s.sub(/\A\s+/, "")
448
+ loop do
449
+ first = first_grapheme(remaining)
450
+ break unless decorative_grapheme?(first)
451
+
452
+ remaining = remaining[first.length..].to_s.sub(/\A\s+/, "")
453
+ end
454
+ remaining
455
+ end
456
+
457
+ def first_grapheme(text)
458
+ text.to_s.strip[/\A\X/u].to_s
459
+ end
460
+
461
+ def decorative_grapheme?(grapheme)
462
+ value = grapheme.to_s
463
+ return false if value.empty?
464
+
465
+ !value.match?(/\A[[:alnum:][:space:]]\z/u)
466
+ end
467
+
468
+ def repair_gemspec_homepage(project_root, env)
469
+ gemspec_path = Dir.glob(File.join(project_root.to_s, "*.gemspec")).min
470
+ return nil unless gemspec_path
471
+
472
+ content = File.read(gemspec_path)
473
+ homepage_record = Kettle::Jem.gemspec_assignment_records(content, receiver: "spec")
474
+ .find { |record| record.fetch(:field) == "homepage" }
475
+ unless homepage_record
476
+ return {
477
+ name: "gemspec_homepage_literal",
478
+ status: "skipped",
479
+ reason: "missing_homepage"
480
+ }
481
+ end
482
+
483
+ assigned = homepage_record[:value]
484
+ if literal_github_homepage?(assigned)
485
+ return {
486
+ name: "gemspec_homepage_literal",
487
+ path: File.basename(gemspec_path),
488
+ status: "already_current"
489
+ }
490
+ end
491
+
492
+ org = github_org_from_env(env) || github_org_from_origin(project_root)
493
+ gem_name = gemspec_name(content, gemspec_path)
494
+ if org.to_s.empty? || gem_name.to_s.empty?
495
+ return {
496
+ name: "gemspec_homepage_literal",
497
+ path: File.basename(gemspec_path),
498
+ status: "skipped",
499
+ reason: "missing_github_org"
500
+ }
501
+ end
502
+
503
+ homepage = "https://github.com/#{org}/#{gem_name}"
504
+ receiver = homepage_record.fetch(:receiver)
505
+ field = homepage_record.fetch(:field)
506
+ indent = Kettle::Jem.leading_whitespace(homepage_record.fetch(:source))
507
+ updated = Kettle::Jem.replace_source_range_lines(
508
+ content,
509
+ homepage_record.fetch(:start_line),
510
+ homepage_record.fetch(:end_line),
511
+ "#{indent}#{receiver}.#{field} = #{homepage.dump}\n"
512
+ )
513
+ File.write(gemspec_path, updated) if updated != content
514
+ {
515
+ name: "gemspec_homepage_literal",
516
+ path: File.basename(gemspec_path),
517
+ status: (updated == content) ? "already_current" : "applied",
518
+ homepage: homepage
519
+ }
520
+ end
521
+
522
+ def literal_github_homepage?(assigned)
523
+ value = assigned.to_s.strip
524
+ return false if value.include?('#{')
525
+
526
+ if (value.start_with?('"') && value.end_with?('"')) || (value.start_with?("'") && value.end_with?("'"))
527
+ value = value[1..-2]
528
+ end
529
+ uri = URI.parse(value)
530
+ segments = uri.path.to_s.split("/").reject(&:empty?)
531
+ %w[http https].include?(uri.scheme) && uri.host == "github.com" && segments.length == 2
532
+ rescue URI::InvalidURIError
533
+ false
534
+ end
535
+
536
+ def github_org_from_env(env)
537
+ %w[FORGE_ORG KJ_GH_ORG GITHUB_ORG].each do |key|
538
+ value = (env || {})[key].to_s.strip
539
+ return value unless value.empty? || Kettle::Jem::DecisionPolicy.falsey?(value)
540
+ end
541
+ nil
542
+ end
543
+
544
+ def github_org_from_origin(project_root)
545
+ stdout, _stderr, status = Open3.capture3("git", "-C", project_root.to_s, "remote", "get-url", "origin")
546
+ return nil unless status.success?
547
+
548
+ remote = stdout.to_s.strip
549
+ path = if remote.start_with?("git@github.com:")
550
+ remote.split(":", 2).last
551
+ else
552
+ uri = URI.parse(remote)
553
+ return nil unless uri.host == "github.com"
554
+
555
+ uri.path
556
+ end
557
+ path.to_s.delete_prefix("/").split("/").first
558
+ rescue URI::InvalidURIError
559
+ nil
560
+ end
561
+
562
+ def gemspec_name(content, gemspec_path)
563
+ Kettle::Jem.gemspec_assignment_records(content, receiver: "spec")
564
+ .find { |record| record.fetch(:field) == "name" }
565
+ &.fetch(:value) ||
566
+ File.basename(gemspec_path, ".gemspec")
567
+ end
568
+
569
+ def ensure_env_local_gitignore(project_root)
570
+ return nil unless File.file?(File.join(project_root.to_s, ".env.local.example"))
571
+
572
+ gitignore_path = File.join(project_root.to_s, ".gitignore")
573
+ content = File.file?(gitignore_path) ? File.read(gitignore_path) : ""
574
+ if content.lines.any? { |line| line.strip == ".env.local" }
575
+ return {
576
+ name: "env_local_gitignore",
577
+ path: ".gitignore",
578
+ status: "already_current"
579
+ }
580
+ end
581
+
582
+ addition = [
583
+ "# Local environment overrides (KEY=value, loaded by mise via dotenvy)",
584
+ ".env.local"
585
+ ].join("\n")
586
+ updated = content.dup
587
+ updated << "\n" unless updated.empty? || updated.end_with?("\n")
588
+ updated << addition << "\n"
589
+ File.write(gitignore_path, updated)
590
+ {
591
+ name: "env_local_gitignore",
592
+ path: ".gitignore",
593
+ status: "applied"
594
+ }
595
+ end
596
+
597
+ def run_bundle_setup_commands(project_root, env:, run_options:, command_runner:)
598
+ quiet = Kettle::Jem::DecisionPolicy.value_to_boolean(run_options[:quiet])
599
+ steps = [
600
+ run_command_step(
601
+ "bin_setup",
602
+ bin_setup_command(project_root, quiet: quiet),
603
+ project_root: project_root,
604
+ env: env,
605
+ quiet: quiet,
606
+ command_runner: command_runner
607
+ )
608
+ ]
609
+ steps << if Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:skip_binstubs])
610
+ {
611
+ name: "bundle_binstubs",
612
+ status: "skipped",
613
+ reason: "skip_binstubs"
614
+ }
615
+ else
616
+ run_command_step(
617
+ "bundle_binstubs",
618
+ bundle_binstubs_command(project_root, env: env),
619
+ project_root: project_root,
620
+ env: env,
621
+ quiet: quiet,
622
+ command_runner: command_runner
623
+ )
624
+ end
625
+ if steps.any? { |step| step.fetch(:name) == "bundle_binstubs" && step.fetch(:status) == "succeeded" }
626
+ steps << rewrite_yard_binstub(project_root)
627
+ steps << prune_unwanted_bundler_binstubs(project_root)
628
+ steps << ensure_curated_binstubs_executable(project_root)
629
+ steps << validate_bundle_binstub_location(project_root)
630
+ end
631
+ steps
632
+ end
633
+
634
+ def appraisal_generate_step(project_root, env: nil, run_options: {})
635
+ if Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:skip_appraisal_generate])
636
+ return {
637
+ name: "appraisal_generate",
638
+ status: "skipped",
639
+ reason: "skip_appraisal_generate"
640
+ }
641
+ end
642
+
643
+ unless Array((run_options || {})[:only]).empty?
644
+ return {
645
+ name: "appraisal_generate",
646
+ status: "skipped",
647
+ reason: "template_selection"
648
+ }
649
+ end
650
+
651
+ unless File.file?(File.join(project_root.to_s, "Appraisals")) && File.file?(File.join(project_root.to_s, "bin", "rake"))
652
+ return {
653
+ name: "appraisal_generate",
654
+ status: "skipped",
655
+ reason: "missing_appraisals_entrypoint"
656
+ }
657
+ end
658
+
659
+ unless rake_task_available?(project_root, "appraisal:generate", env: env)
660
+ return {
661
+ name: "appraisal_generate",
662
+ status: "skipped",
663
+ reason: "missing_appraisal_generate_task"
664
+ }
665
+ end
666
+
667
+ {
668
+ name: "appraisal_generate",
669
+ command: ["bin/rake", "appraisal:generate"],
670
+ status: "ready",
671
+ reason: "post_template_appraisal_generation"
672
+ }
673
+ end
674
+
675
+ def bundle_binstubs_command(project_root = Dir.pwd, env: ENV)
676
+ %w[bundle binstubs] + curated_binstub_gems_in_bundle(project_root, env: env)
677
+ end
678
+
679
+ def curated_binstub_gems_in_bundle(project_root = Dir.pwd, env: ENV)
680
+ stdout, _stderr, status = Open3.capture3(env.to_h, "bundle", "list", "--name-only", chdir: project_root.to_s)
681
+ return CURATED_BINSTUB_GEMS unless status.success?
682
+
683
+ bundled = stdout.lines.map(&:strip).reject(&:empty?)
684
+ CURATED_BINSTUB_GEMS & bundled
685
+ end
686
+
687
+ def bundle_includes_gem?(project_root, gem_name, env: ENV)
688
+ stdout, _stderr, status = Open3.capture3(env.to_h, "bundle", "list", "--name-only", chdir: project_root.to_s)
689
+ return true unless status.success?
690
+
691
+ stdout.lines.map(&:strip).include?(gem_name.to_s)
692
+ end
693
+
694
+ def normalize_lockfile_step(project_root, env:, run_options: {})
695
+ if Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:skip_lock_normalization])
696
+ return {
697
+ name: "bundle_lock_normalization",
698
+ status: "skipped",
699
+ reason: "skip_lock_normalization"
700
+ }
701
+ end
702
+
703
+ if local_path_development_env?(env)
704
+ return {
705
+ name: "bundle_lock_normalization",
706
+ status: "skipped",
707
+ reason: "local_path_development_env"
708
+ }
709
+ end
710
+
711
+ unless File.file?(File.join(project_root.to_s, "Gemfile.lock"))
712
+ return {
713
+ name: "bundle_lock_normalization",
714
+ status: "skipped",
715
+ reason: "missing Gemfile.lock"
716
+ }
717
+ end
718
+
719
+ {
720
+ name: "bundle_lock_normalization",
721
+ command: lockfile_normalization_command,
722
+ status: "ready",
723
+ env: normal_lockfile_env(project_root, env),
724
+ reason: "bundle_update_without_templating_overrides"
725
+ }
726
+ end
727
+
728
+ def lockfile_normalization_command
729
+ %w[bundle update]
730
+ end
731
+
732
+ def distinct_bundle_install_step(project_root, env:, setup_env:)
733
+ bundle_env = bundler_command_env(project_root, env)
734
+ if same_bundle_env?(bundle_env, setup_env)
735
+ return {
736
+ name: "bundle_install_requested_env",
737
+ status: "skipped",
738
+ reason: "same_as_setup_bundle_env"
739
+ }
740
+ end
741
+
742
+ {
743
+ name: "bundle_install_requested_env",
744
+ command: %w[bundle install],
745
+ status: "ready",
746
+ env: bundle_env,
747
+ reason: "distinct_bundle_env"
748
+ }
749
+ end
750
+
751
+ def run_distinct_bundle_install_step(project_root, env:, setup_env:, run_options:, command_runner:)
752
+ step = distinct_bundle_install_step(project_root, env: env, setup_env: setup_env)
753
+ return step unless step.fetch(:status) == "ready"
754
+
755
+ execute_ready_command_step(
756
+ step,
757
+ project_root: project_root,
758
+ env: setup_env,
759
+ quiet: Kettle::Jem::DecisionPolicy.value_to_boolean(run_options[:quiet]),
760
+ command_runner: command_runner
761
+ )
762
+ end
763
+
764
+ def rubocop_gradual_autocorrect_step(project_root, env: nil, run_options: {})
765
+ if Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:skip_rubocop_gradual])
766
+ return {
767
+ name: "rubocop_gradual_autocorrect",
768
+ status: "skipped",
769
+ reason: "skip_rubocop_gradual"
770
+ }
771
+ end
772
+
773
+ rakefile = File.join(project_root.to_s, "Rakefile")
774
+ bin_rake = File.join(project_root.to_s, "bin", "rake")
775
+ unless File.file?(rakefile) && File.file?(bin_rake)
776
+ return {
777
+ name: "rubocop_gradual_autocorrect",
778
+ status: "skipped",
779
+ reason: "missing_rake_entrypoint"
780
+ }
781
+ end
782
+ unless rake_task_available?(project_root, "rubocop_gradual:autocorrect", env: env)
783
+ return {
784
+ name: "rubocop_gradual_autocorrect",
785
+ status: "skipped",
786
+ reason: "missing_rubocop_gradual_task"
787
+ }
788
+ end
789
+
790
+ {
791
+ name: "rubocop_gradual_autocorrect",
792
+ command: rubocop_gradual_autocorrect_command,
793
+ status: "ready",
794
+ reason: "post_template_style_normalization"
795
+ }
796
+ end
797
+
798
+ def rubocop_gradual_autocorrect_command
799
+ ["sh", "-c", "rm -f .rubocop_gradual.lock && bin/rake rubocop_gradual:autocorrect"]
800
+ end
801
+
802
+ def rake_task_available?(project_root, task_name, env: nil)
803
+ stdout, _stderr, status = Open3.capture3((env || {}).to_h, "bin/rake", "--tasks", chdir: project_root.to_s)
804
+ status.success? && stdout.lines.any? { |line| line.include?(task_name.to_s) }
805
+ rescue Errno::EACCES, Errno::ENOENT
806
+ false
807
+ end
808
+
809
+ def rubocop_lts_local_branch_step(report, env:, project_root: nil)
810
+ local_root = rubocop_lts_local_root(env)
811
+ return nil unless local_root
812
+
813
+ ruby_gem = report.dig(:facts, :templates, :tokens, "KJ|RUBOCOP_RUBY_GEM").to_s
814
+ branch = Kettle::Rb::CompatMatrix.rubocop_lts_branch_for_gem(ruby_gem)
815
+ unless branch
816
+ raise Kettle::Jem::Error, "Cannot select RUBOCOP_LTS_LOCAL branch for #{ruby_gem.inspect}"
817
+ end
818
+
819
+ checkout = File.join(local_root, "rubocop-lts")
820
+ if project_root && same_path?(project_root, checkout)
821
+ return {
822
+ name: "rubocop_lts_local_branch",
823
+ status: "skipped",
824
+ path: checkout,
825
+ branch: branch,
826
+ reason: "destination_is_rubocop_lts_checkout"
827
+ }
828
+ end
829
+
830
+ current = current_git_branch(checkout)
831
+ if current == branch
832
+ return {
833
+ name: "rubocop_lts_local_branch",
834
+ status: "already_current",
835
+ path: checkout,
836
+ branch: branch
837
+ }
838
+ end
839
+
840
+ {
841
+ name: "rubocop_lts_local_branch",
842
+ command: %W[git -C #{checkout} switch #{branch}],
843
+ status: "ready",
844
+ path: checkout,
845
+ current_branch: current,
846
+ branch: branch,
847
+ reason: "rubocop_lts_local_branch_matrix"
848
+ }
849
+ end
850
+
851
+ def same_path?(left, right)
852
+ File.realpath(left.to_s) == File.realpath(right.to_s)
853
+ rescue Errno::ENOENT
854
+ File.expand_path(left.to_s) == File.expand_path(right.to_s)
855
+ end
856
+
857
+ def rubocop_lts_local_root(env)
858
+ value = (env || {})["RUBOCOP_LTS_LOCAL"].to_s.strip
859
+ return nil if value.empty? || Kettle::Jem::DecisionPolicy.falsey?(value)
860
+ return File.join((env || {})["HOME"].to_s.empty? ? Dir.home : (env || {})["HOME"].to_s, "src", "rubocop-lts") if value.casecmp("true").zero? || value == "1" || value.casecmp("yes").zero? || value.casecmp("on").zero?
861
+ return value if value.start_with?("/")
862
+
863
+ File.join((env || {})["HOME"].to_s.empty? ? Dir.home : (env || {})["HOME"].to_s, value)
864
+ end
865
+
866
+ def current_git_branch(path)
867
+ stdout, _stderr, status = Open3.capture3("git", "-C", path.to_s, "branch", "--show-current")
868
+ return "" unless status.success?
869
+
870
+ stdout.to_s.strip
871
+ end
872
+
873
+ def normal_lockfile_env(project_root, env)
874
+ command_env = (env || {}).to_h.dup
875
+ strip_inherited_bundler_activation!(command_env)
876
+ command_env["K_JEM_TEMPLATING"] = "false"
877
+ %w[KETTLE_DEV_DEV GALTZO_FLOSS_DEV STRUCTUREDMERGE_DEV].each do |key|
878
+ command_env[key] = "false" if command_env.key?(key)
879
+ end
880
+ gemfile = File.join(project_root.to_s, "Gemfile")
881
+ command_env["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
882
+ apply_direct_sibling_lockfile_env!(project_root, command_env)
883
+ %w[
884
+ BUNDLE_PATH
885
+ BUNDLE_WITH
886
+ BUNDLE_WITHOUT
887
+ ].each do |key|
888
+ command_env[key] = nil
889
+ end
890
+ command_env
891
+ end
892
+
893
+ def apply_direct_sibling_lockfile_env!(project_root, command_env)
894
+ gemspec_path = Dir.glob(File.join(project_root.to_s, "*.gemspec")).min
895
+ return unless gemspec_path
896
+
897
+ metadata = Kettle::Jem.send(:project_gemspec_metadata, project_root, gemspec_path)
898
+ package_name = metadata[:gem_name] || metadata["gem_name"] || File.basename(gemspec_path, ".gemspec")
899
+ direct_sibling_gems = Kettle::Jem.send(
900
+ :direct_sibling_runtime_gems,
901
+ project_root,
902
+ metadata,
903
+ package_name: package_name
904
+ )
905
+ return if direct_sibling_gems.empty?
906
+
907
+ source_url = metadata[:source_code_uri] || metadata["source_code_uri"] ||
908
+ metadata[:homepage] || metadata["homepage"]
909
+ workspace_slug = Kettle::Jem.send(:direct_sibling_workspace_slug, source_url, project_root)
910
+ prefix = workspace_slug.to_s.upcase.tr("-", "_")
911
+ prefix = "LOCAL" if prefix.empty?
912
+ dev_env = "#{prefix}_DEV"
913
+ command_env[dev_env] = File.expand_path("..", project_root.to_s) if local_env_disabled?(command_env[dev_env])
914
+ end
915
+
916
+ def validate_bundle_binstub_location(project_root)
917
+ destination_bin = File.join(project_root.to_s, "bin")
918
+ destination_binstubs = binstub_files(destination_bin)
919
+ parent_root = git_toplevel(project_root)
920
+ parent_binstubs = if parent_root && File.expand_path(parent_root) != File.expand_path(project_root.to_s)
921
+ binstub_files(File.join(parent_root, "bin"))
922
+ else
923
+ []
924
+ end
925
+
926
+ if destination_binstubs.empty? && parent_binstubs.any?
927
+ return {
928
+ name: "bundle_binstub_location_validation",
929
+ status: "warning",
930
+ reason: "parent_bin_has_binstubs_but_destination_bin_has_none",
931
+ destination_bin: relative_or_absolute_path(destination_bin, project_root),
932
+ parent_bin: relative_or_absolute_path(File.join(parent_root, "bin"), project_root),
933
+ parent_binstubs: parent_binstubs.map { |path| File.basename(path) }.sort
934
+ }
935
+ end
936
+
937
+ {
938
+ name: "bundle_binstub_location_validation",
939
+ status: destination_binstubs.empty? ? "unverified" : "succeeded",
940
+ reason: destination_binstubs.empty? ? "no_destination_binstubs_found" : "destination_bin_has_binstubs",
941
+ destination_bin: relative_or_absolute_path(destination_bin, project_root),
942
+ destination_binstubs: destination_binstubs.map { |path| File.basename(path) }.sort
943
+ }
944
+ end
945
+
946
+ def rewrite_yard_binstub(project_root)
947
+ yard_binstub = File.join(project_root.to_s, "bin", "yard")
948
+ unless File.file?(yard_binstub)
949
+ return {
950
+ name: "yard_binstub_rake_handoff",
951
+ status: "skipped",
952
+ reason: "missing_yard_binstub",
953
+ path: "bin/yard"
954
+ }
955
+ end
956
+
957
+ content = yard_binstub_rake_handoff_content
958
+ if File.read(yard_binstub) == content
959
+ return {
960
+ name: "yard_binstub_rake_handoff",
961
+ status: "already_current",
962
+ reason: "yard_binstub_already_runs_rake_yard",
963
+ path: "bin/yard"
964
+ }
965
+ end
966
+
967
+ executable = File.executable?(yard_binstub)
968
+ File.write(yard_binstub, content)
969
+ FileUtils.chmod("+x", yard_binstub) if executable
970
+ {
971
+ name: "yard_binstub_rake_handoff",
972
+ status: "updated",
973
+ reason: "yard_plugins_require_rake_yard_postprocess_hooks",
974
+ path: "bin/yard"
975
+ }
976
+ end
977
+
978
+ def yard_binstub_rake_handoff_content
979
+ <<~RUBY
980
+ #!/usr/bin/env ruby
981
+ # frozen_string_literal: true
982
+
983
+ # Generated by kettle-jem after curated `bundle binstubs`.
984
+ #
985
+ # Bundler's normal yard binstub executes the raw YARD CLI. That skips
986
+ # Rake task enhancements installed by documentation plugins such as
987
+ # yard-timekeeper. Those plugins run after YARD finishes and may restore
988
+ # checked-in docs files whose only diff is generated timestamp churn.
989
+ #
990
+ # The canonical docs entrypoint is therefore the Rake task, not raw
991
+ # `yard` / `bin/yard`. Keep this binstub as a handoff to the task so
992
+ # humans and tools that reach for `bin/yard` still run the full docs
993
+ # workflow with post-processing hooks.
994
+ exec("bundle", "exec", "rake", "yard")
995
+ RUBY
996
+ end
997
+
998
+ def ensure_curated_binstubs_executable(project_root)
999
+ bin_dir = File.join(project_root.to_s, "bin")
1000
+ executable_binstubs = binstub_files(bin_dir).select { |path| curated_binstub?(path) }
1001
+ updated = executable_binstubs.filter_map do |path|
1002
+ next if File.executable?(path)
1003
+
1004
+ FileUtils.chmod("+x", path)
1005
+ File.basename(path)
1006
+ end
1007
+
1008
+ {
1009
+ name: "curated_binstubs_executable",
1010
+ status: updated.empty? ? "already_executable" : "updated",
1011
+ path: "bin",
1012
+ executable_binstubs: executable_binstubs.map { |path| File.basename(path) }.sort,
1013
+ updated_binstubs: updated.sort
1014
+ }
1015
+ end
1016
+
1017
+ def prune_unwanted_bundler_binstubs(project_root)
1018
+ bin_dir = File.join(project_root.to_s, "bin")
1019
+ removed = []
1020
+ preserved = []
1021
+ binstub_files(bin_dir).each do |path|
1022
+ basename = File.basename(path)
1023
+ next if curated_binstub?(path)
1024
+ next unless bundler_generated_binstub?(path)
1025
+
1026
+ if tracked_binstub?(project_root, path)
1027
+ preserved << basename
1028
+ next
1029
+ end
1030
+
1031
+ FileUtils.rm_f(path)
1032
+ removed << basename
1033
+ end
1034
+
1035
+ {
1036
+ name: "bundle_binstub_pruning",
1037
+ status: removed.empty? ? "already_current" : "pruned",
1038
+ reason: removed.empty? ? "no_unwanted_bundler_binstubs" : "removed_unwanted_bundler_binstubs",
1039
+ removed_binstubs: removed.sort,
1040
+ preserved_binstubs: preserved.sort
1041
+ }
1042
+ end
1043
+
1044
+ def tracked_binstub?(project_root, path)
1045
+ relative_path = path.to_s.delete_prefix("#{File.expand_path(project_root.to_s)}/")
1046
+ _stdout, _stderr, status = Open3.capture3(
1047
+ "git",
1048
+ "-C",
1049
+ project_root.to_s,
1050
+ "ls-files",
1051
+ "--error-unmatch",
1052
+ "--",
1053
+ relative_path
1054
+ )
1055
+ status.success?
1056
+ rescue
1057
+ false
1058
+ end
1059
+
1060
+ def binstub_files(bin_dir)
1061
+ return [] unless File.directory?(bin_dir)
1062
+
1063
+ Dir.glob(File.join(bin_dir, "*")).select do |path|
1064
+ next false unless File.file?(path)
1065
+ next false if File.basename(path) == "setup"
1066
+
1067
+ content = File.read(path, 256)
1068
+ content.start_with?("#!") && content.include?("ruby")
1069
+ rescue
1070
+ false
1071
+ end
1072
+ end
1073
+
1074
+ def curated_binstub?(path)
1075
+ basename = File.basename(path)
1076
+ return true if CURATED_BINSTUB_DISCRETE_EXECUTABLES.include?(basename)
1077
+
1078
+ gem_name = bundler_binstub_gem_name(path)
1079
+ !gem_name.to_s.empty? && CURATED_BINSTUB_GEMS.include?(gem_name)
1080
+ end
1081
+
1082
+ def bundler_generated_binstub?(path)
1083
+ File.read(path, 512).include?("This file was generated by Bundler")
1084
+ rescue
1085
+ false
1086
+ end
1087
+
1088
+ def bundler_binstub_gem_name(path)
1089
+ content = File.read(path)
1090
+ return unless content.include?("This file was generated by Bundler")
1091
+
1092
+ Kettle::Jem.ruby_call_records(content, :bin_path).filter_map do |call|
1093
+ next unless call.receiver&.slice == "Gem"
1094
+
1095
+ argument = call.arguments&.arguments&.first
1096
+ next unless argument.is_a?(::Prism::StringNode)
1097
+
1098
+ argument.unescaped
1099
+ end.first
1100
+ rescue
1101
+ nil
1102
+ end
1103
+
1104
+ def git_toplevel(project_root)
1105
+ stdout, _stderr, status = Open3.capture3("git", "-C", project_root.to_s, "rev-parse", "--show-toplevel")
1106
+ status.success? ? stdout.strip : nil
1107
+ end
1108
+
1109
+ def relative_or_absolute_path(path, project_root)
1110
+ expanded_path = File.expand_path(path.to_s)
1111
+ expanded_root = File.expand_path(project_root.to_s)
1112
+ return "." if expanded_path == expanded_root
1113
+ return expanded_path.delete_prefix("#{expanded_root}/") if expanded_path.start_with?("#{expanded_root}/")
1114
+
1115
+ expanded_path
1116
+ end
1117
+
1118
+ def execute_orchestration_steps(install_steps, project_root:, env:, run_options:, command_runner:, event_phase: "install")
1119
+ quiet = Kettle::Jem::DecisionPolicy.value_to_boolean(run_options[:quiet])
1120
+ events = Kettle::Jem.event_stream_from_options(run_options)
1121
+ install_steps.map do |step|
1122
+ Kettle::Jem.emit_step_event(
1123
+ events,
1124
+ "command_step",
1125
+ step.merge(status: "started"),
1126
+ phase: event_phase
1127
+ )
1128
+ result = case step.fetch(:name)
1129
+ when "mise_trust"
1130
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1131
+ when "bundled_handoff"
1132
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1133
+ when "bundle_lock_normalization"
1134
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1135
+ when "rubocop_lts_local_branch"
1136
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1137
+ when "bundle_install_requested_env"
1138
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1139
+ when "appraisal_generate"
1140
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1141
+ when "rubocop_gradual_autocorrect"
1142
+ execute_ready_command_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1143
+ when "hook_templates"
1144
+ execute_hook_templates_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1145
+ when "git_drivers"
1146
+ execute_git_drivers_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1147
+ when "bootstrap_commit"
1148
+ execute_ready_commands_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1149
+ else
1150
+ step
1151
+ end
1152
+ Kettle::Jem.emit_step_event(events, "command_step", result, phase: event_phase)
1153
+ result
1154
+ end
1155
+ end
1156
+
1157
+ def setup_command_env(project_root, env)
1158
+ command_env = (env || {}).to_h.dup
1159
+ templating_requested = command_env.fetch("K_JEM_TEMPLATING", "false").casecmp("true").zero?
1160
+ requested_bundler_version = command_env["KJ_BUNDLER_VERSION"].to_s.strip
1161
+ requested_gemfile = command_env["BUNDLE_GEMFILE"].to_s
1162
+ disable_checksum_validation = command_env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"]
1163
+ strip_inherited_bundler_activation!(command_env)
1164
+ command_env["BUNDLER_VERSION"] = requested_bundler_version unless requested_bundler_version.empty?
1165
+ gemfile = File.join(project_root.to_s, "Gemfile")
1166
+ if File.file?(gemfile) || (!requested_gemfile.empty? && same_path?(requested_gemfile, gemfile))
1167
+ command_env["BUNDLE_GEMFILE"] = gemfile
1168
+ end
1169
+ command_env["K_JEM_TEMPLATING"] = "true" if templating_requested
1170
+ command_env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"] = disable_checksum_validation unless disable_checksum_validation.nil?
1171
+ apply_kettle_family_local_install_env!(command_env)
1172
+ command_env["K_JEM_TEMPLATING"] = if templating_requested && local_path_development_env?(command_env)
1173
+ "true"
1174
+ else
1175
+ "false"
1176
+ end
1177
+ command_env
1178
+ end
1179
+
1180
+ def bundler_command_env(project_root, env)
1181
+ command_env = (env || {}).to_h.dup
1182
+ requested_bundler_version = command_env["KJ_BUNDLER_VERSION"].to_s.strip
1183
+ disable_checksum_validation = command_env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"]
1184
+ strip_inherited_bundler_activation!(command_env)
1185
+ command_env["BUNDLER_VERSION"] = requested_bundler_version unless requested_bundler_version.empty?
1186
+ gemfile = File.join(project_root.to_s, "Gemfile")
1187
+ command_env["BUNDLE_GEMFILE"] = gemfile if File.file?(gemfile)
1188
+ command_env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"] = disable_checksum_validation unless disable_checksum_validation.nil?
1189
+ command_env["K_JEM_TEMPLATING"] = "false" unless command_env.key?("K_JEM_TEMPLATING")
1190
+ apply_kettle_family_local_install_env!(command_env)
1191
+ command_env
1192
+ end
1193
+
1194
+ def same_bundle_env?(left, right)
1195
+ bundle_env_fingerprint(left) == bundle_env_fingerprint(right)
1196
+ end
1197
+
1198
+ def bundle_env_fingerprint(env)
1199
+ env.to_h.transform_values { |value| value&.to_s }.reject { |key, _value| ignored_bundle_env_key?(key) }
1200
+ end
1201
+
1202
+ def ignored_bundle_env_key?(key)
1203
+ key.to_s.match?(/\A(?:BUNDLE_BIN_PATH|BUNDLE_LOCKFILE|BUNDLER_SETUP|BUNDLER_VERSION|RUBYLIB|RUBYOPT)\z/)
1204
+ end
1205
+
1206
+ def apply_kettle_family_local_install_env!(command_env)
1207
+ return unless command_env.fetch("K_JEM_TEMPLATING", "false").casecmp("true").zero?
1208
+
1209
+ marker = kettle_family_local_install_marker(command_env)
1210
+ return unless Array(marker["installed_members"]).include?("kettle-jem")
1211
+
1212
+ members_root = marker["members_root"].to_s
1213
+ if !command_env.key?("STRUCTUREDMERGE_DEV") && Dir.exist?(File.join(members_root, "kettle-jem"))
1214
+ command_env["STRUCTUREDMERGE_DEV"] = members_root
1215
+ end
1216
+
1217
+ kettle_root = kettle_family_dependency_root(marker)
1218
+ command_env["KETTLE_DEV_DEV"] = kettle_root if kettle_root && !command_env.key?("KETTLE_DEV_DEV")
1219
+ end
1220
+
1221
+ def kettle_family_local_install_marker(command_env)
1222
+ path = command_env["KETTLE_FAMILY_LOCAL_INSTALL_MARKER"].to_s
1223
+ path = File.join(Dir.home, ".kettle-family", "local-install.json") if path.empty?
1224
+ return {} unless File.file?(path)
1225
+
1226
+ JSON.parse(File.read(path))
1227
+ rescue JSON::ParserError
1228
+ {}
1229
+ end
1230
+
1231
+ def kettle_family_dependency_root(marker)
1232
+ Array(marker["local_dependencies"]).map(&:to_s).each do |path|
1233
+ return File.dirname(path) if File.basename(path) == "kettle-dev"
1234
+ end
1235
+ nil
1236
+ end
1237
+
1238
+ def local_env_disabled?(value)
1239
+ raw_value = value.to_s.strip
1240
+ raw_value.empty? || Kettle::Jem::DecisionPolicy.falsey?(raw_value)
1241
+ end
1242
+
1243
+ def local_path_development_env?(env)
1244
+ (env || {}).any? do |key, value|
1245
+ key.to_s.end_with?("_DEV") && !local_env_disabled?(value)
1246
+ end
1247
+ end
1248
+
1249
+ def strip_inherited_bundler_activation!(command_env)
1250
+ (ENV.keys + command_env.keys).grep(/\ABUNDLE_/).each { |key| command_env[key] = nil }
1251
+ (ENV.keys + command_env.keys).grep(/\ABUNDLER_/).each { |key| command_env[key] = nil }
1252
+ (ENV.keys + command_env.keys).grep(/\AGIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)\z/).each { |key| command_env[key] = nil }
1253
+ %w[RUBYLIB RUBYOPT].each { |key| command_env[key] = nil }
1254
+ end
1255
+
1256
+ def hook_templates_step(project_root, run_options)
1257
+ mode = normalize_hook_templates_mode((run_options || {})[:hook_templates])
1258
+ if mode.empty? || mode == "none"
1259
+ return {
1260
+ name: "hook_templates",
1261
+ status: "skipped",
1262
+ reason: "not_requested"
1263
+ }
1264
+ end
1265
+
1266
+ if mode == "global"
1267
+ return {
1268
+ name: "hook_templates",
1269
+ status: "unsupported",
1270
+ reason: "global_hooks_not_implemented",
1271
+ requested: mode
1272
+ }
1273
+ end
1274
+
1275
+ hooks_dir = File.join(project_root.to_s, ".git-hooks")
1276
+ missing_hooks = %w[commit-msg prepare-commit-msg].reject { |hook| File.file?(File.join(hooks_dir, hook)) }
1277
+ unless missing_hooks.empty?
1278
+ return {
1279
+ name: "hook_templates",
1280
+ status: "unavailable",
1281
+ reason: "missing_local_hook_templates",
1282
+ missing_hooks: missing_hooks
1283
+ }
1284
+ end
1285
+
1286
+ {
1287
+ name: "hook_templates",
1288
+ status: "ready",
1289
+ command: %w[git config core.hooksPath .git-hooks],
1290
+ chmod_paths: %w[.git-hooks/commit-msg .git-hooks/prepare-commit-msg],
1291
+ reason: "ready_for_local_hooks"
1292
+ }
1293
+ end
1294
+
1295
+ def normalize_hook_templates_mode(value)
1296
+ normalized = value.to_s.strip.downcase
1297
+ return "" if normalized.empty?
1298
+ return "none" if %w[0 false f no n none off skip].include?(normalized)
1299
+ return "local" if %w[1 true t yes y on l local].include?(normalized)
1300
+ return "global" if %w[g global].include?(normalized)
1301
+
1302
+ normalized
1303
+ end
1304
+
1305
+ DEFAULT_GIT_DRIVER_DEFINITIONS = Ractor.make_shareable([
1306
+ {
1307
+ language: "ruby",
1308
+ pattern: "*.rb",
1309
+ diff: "smorg-rb",
1310
+ merge: "smorg-rb",
1311
+ diff_command: "smorg-rb diff-driver",
1312
+ merge_command: "smorg-rb merge-driver %O %A %B %P"
1313
+ },
1314
+ {
1315
+ language: "go",
1316
+ pattern: "*.go",
1317
+ diff: "smorg-go",
1318
+ merge: "smorg-go",
1319
+ diff_command: "smorg-go diff-driver",
1320
+ merge_command: "smorg-go merge-driver %O %A %B %P"
1321
+ },
1322
+ {
1323
+ language: "rust",
1324
+ pattern: "*.rs",
1325
+ diff: "smorg-rs",
1326
+ merge: "smorg-rs",
1327
+ diff_command: "smorg-rs diff-driver",
1328
+ merge_command: "smorg-rs merge-driver %O %A %B %P"
1329
+ }
1330
+ ])
1331
+ GIT_DRIVER_LANGUAGE_REGISTRY = Ractor.make_shareable(DEFAULT_GIT_DRIVER_DEFINITIONS.to_h { |definition| [definition.fetch(:language), definition] })
1332
+
1333
+ BUILTIN_GIT_DIFF_ATTRIBUTES = Ractor.make_shareable([
1334
+ {path: ".gitattributes", pattern: "*.rb", attributes: {"diff" => "ruby"}},
1335
+ {path: ".gitattributes", pattern: "*.go", attributes: {"diff" => "golang"}},
1336
+ {path: ".gitattributes", pattern: "*.rs", attributes: {"diff" => "rust"}}
1337
+ ])
1338
+
1339
+ def git_drivers_step(project_root, run_options)
1340
+ mode = normalize_git_drivers_mode((run_options || {})[:git_drivers])
1341
+ dry_run = Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:dry_run])
1342
+ manifest = git_driver_manifest(project_root)
1343
+ if mode == "none"
1344
+ return {
1345
+ name: "git_drivers",
1346
+ status: "skipped",
1347
+ reason: "not_requested",
1348
+ mode: mode
1349
+ }
1350
+ end
1351
+
1352
+ case mode
1353
+ when "check"
1354
+ {
1355
+ name: "git_drivers",
1356
+ status: "ready",
1357
+ mode: mode,
1358
+ profile: "semantic-diff",
1359
+ scope: "check",
1360
+ attribute_updates: git_driver_attribute_updates(manifest, "semantic-diff"),
1361
+ config_checks: git_driver_local_config_checks(manifest, "semantic-diff"),
1362
+ reason: "ready_for_git_driver_check"
1363
+ }
1364
+ when "undo"
1365
+ {
1366
+ name: "git_drivers",
1367
+ status: dry_run ? "planned" : "ready",
1368
+ mode: mode,
1369
+ profile: "all",
1370
+ scope: "local",
1371
+ attribute_removals: [
1372
+ {path: ".gitattributes", managed_block: "structuredmerge:git-drivers"},
1373
+ {path: ".gitattributes", managed_block: "structuredmerge:git-builtins"}
1374
+ ],
1375
+ commands: git_driver_global_unset_commands,
1376
+ reason: dry_run ? "dry_run_git_driver_undo" : "ready_for_git_driver_undo"
1377
+ }
1378
+ when "global"
1379
+ {
1380
+ name: "git_drivers",
1381
+ status: dry_run ? "planned" : "ready",
1382
+ mode: mode,
1383
+ profile: "semantic-diff",
1384
+ scope: "global",
1385
+ commands: git_driver_global_commands(manifest, "semantic-diff"),
1386
+ diagnostics: [git_driver_forge_warning_diagnostic],
1387
+ reason: dry_run ? "dry_run_global_git_drivers" : "ready_for_global_git_drivers"
1388
+ }
1389
+ when "include-file"
1390
+ {
1391
+ name: "git_drivers",
1392
+ status: dry_run ? "planned" : "ready",
1393
+ mode: mode,
1394
+ profile: "semantic-diff",
1395
+ scope: "include-file",
1396
+ include_file: ".git/smorg/config",
1397
+ config_entries: git_driver_global_commands(manifest, "semantic-diff").map { |command| {key: command[3], value: command[4]} },
1398
+ commands: [["git", "config", "--local", "include.path", ".git/smorg/config"]],
1399
+ diagnostics: [git_driver_forge_warning_diagnostic],
1400
+ reason: dry_run ? "dry_run_git_driver_include_file" : "ready_for_git_driver_include_file"
1401
+ }
1402
+ when "builtin-diff"
1403
+ updates = git_driver_attribute_updates(manifest, "builtin-diff")
1404
+ diagnostics = git_driver_attribute_diagnostics(project_root, updates, managed_block: "structuredmerge:git-builtins")
1405
+ {
1406
+ name: "git_drivers",
1407
+ status: git_driver_attribute_status(diagnostics, dry_run: dry_run),
1408
+ mode: mode,
1409
+ profile: "builtin-diff",
1410
+ scope: "local",
1411
+ attribute_updates: updates,
1412
+ managed_block: "structuredmerge:git-builtins",
1413
+ commands: [],
1414
+ diagnostics: diagnostics,
1415
+ reason: git_driver_attribute_reason(diagnostics, dry_run: dry_run, ready: "ready_for_builtin_git_attributes")
1416
+ }
1417
+ else
1418
+ updates = git_driver_attribute_updates(manifest, "semantic-diff")
1419
+ diagnostics = git_driver_attribute_diagnostics(project_root, updates, managed_block: "structuredmerge:git-drivers")
1420
+ {
1421
+ name: "git_drivers",
1422
+ status: git_driver_attribute_status(diagnostics, dry_run: dry_run),
1423
+ mode: "local",
1424
+ profile: "semantic-diff",
1425
+ scope: "local",
1426
+ attribute_updates: updates,
1427
+ managed_block: "structuredmerge:git-drivers",
1428
+ commands: git_driver_local_commands(manifest, "semantic-diff"),
1429
+ diagnostics: diagnostics + [git_driver_forge_warning_diagnostic],
1430
+ reason: git_driver_attribute_reason(diagnostics, dry_run: dry_run, ready: "ready_for_local_git_drivers")
1431
+ }
1432
+ end
1433
+ end
1434
+
1435
+ def normalize_git_drivers_mode(value)
1436
+ normalized = value.to_s.strip.downcase.tr("_", "-")
1437
+ return "local" if normalized.empty?
1438
+ return "none" if %w[0 false f no n none off skip].include?(normalized)
1439
+ return "local" if %w[1 true t yes y on l local semantic semantic-diff].include?(normalized)
1440
+ return "global" if %w[g global].include?(normalized)
1441
+ return "include-file" if %w[include include-file].include?(normalized)
1442
+ return "builtin-diff" if %w[b builtin builtin-diff].include?(normalized)
1443
+ return "check" if normalized == "check"
1444
+ return "undo" if normalized == "undo"
1445
+
1446
+ normalized
1447
+ end
1448
+
1449
+ def semantic_git_driver_attribute_updates
1450
+ GIT_DRIVER_LANGUAGE_REGISTRY.values.map do |definition|
1451
+ {
1452
+ path: ".gitattributes",
1453
+ pattern: definition.fetch(:pattern),
1454
+ attributes: {"diff" => definition.fetch(:diff)}
1455
+ }
1456
+ end
1457
+ end
1458
+
1459
+ def git_driver_forge_warning_diagnostic
1460
+ {
1461
+ key: "forge_ignores_external_diff_drivers",
1462
+ severity: "advisory",
1463
+ blocking: false,
1464
+ message: "External StructuredMerge diff drivers are local Git configuration and are generally not honored by hosted forges."
1465
+ }
1466
+ end
1467
+
1468
+ def git_driver_attribute_updates(manifest, profile)
1469
+ profile_hash = git_driver_manifest_profile(manifest, profile)
1470
+ return semantic_git_driver_attribute_updates if profile == "semantic-diff" && !profile_hash
1471
+ return BUILTIN_GIT_DIFF_ATTRIBUTES if profile == "builtin-diff" && !profile_hash
1472
+
1473
+ Array(profile_hash.fetch("attributes", [])).map do |entry|
1474
+ attributes = entry.except("pattern").transform_values(&:to_s)
1475
+ {path: ".gitattributes", pattern: entry.fetch("pattern"), attributes: attributes}
1476
+ end
1477
+ end
1478
+
1479
+ def git_driver_global_commands(manifest = nil, profile = "semantic-diff")
1480
+ profile_hash = git_driver_manifest_profile(manifest, profile)
1481
+ return default_git_driver_global_commands unless profile_hash
1482
+
1483
+ Array(profile_hash.fetch("git_config", [])).map do |entry|
1484
+ ["git", "config", "--global", entry.fetch("key"), entry.fetch("value")]
1485
+ end
1486
+ end
1487
+
1488
+ def git_driver_local_commands(manifest = nil, profile = "semantic-diff")
1489
+ git_driver_global_commands(manifest, profile).map do |command|
1490
+ ["git", "config", "--local", command[3], command[4]]
1491
+ end
1492
+ end
1493
+
1494
+ def default_git_driver_global_commands
1495
+ DEFAULT_GIT_DRIVER_DEFINITIONS.flat_map do |definition|
1496
+ [
1497
+ ["git", "config", "--global", "diff.#{definition.fetch(:diff)}.command", definition.fetch(:diff_command)],
1498
+ ["git", "config", "--global", "merge.#{definition.fetch(:merge)}.driver", definition.fetch(:merge_command)],
1499
+ ["git", "config", "--global", "merge.#{definition.fetch(:merge)}.name", "StructuredMerge #{definition.fetch(:language)} merge driver"]
1500
+ ]
1501
+ end
1502
+ end
1503
+
1504
+ def git_driver_global_unset_commands
1505
+ DEFAULT_GIT_DRIVER_DEFINITIONS.flat_map do |definition|
1506
+ [
1507
+ ["git", "config", "--global", "--unset-all", "diff.#{definition.fetch(:diff)}.command"],
1508
+ ["git", "config", "--global", "--unset-all", "merge.#{definition.fetch(:merge)}.driver"],
1509
+ ["git", "config", "--global", "--unset-all", "merge.#{definition.fetch(:merge)}.name"]
1510
+ ]
1511
+ end
1512
+ end
1513
+
1514
+ def git_driver_global_config_checks(manifest = nil, profile = "semantic-diff")
1515
+ git_driver_global_commands(manifest, profile).map do |command|
1516
+ {key: command[3], expected: command[4], argv: ["git", "config", "--global", "--get", command[3]]}
1517
+ end
1518
+ end
1519
+
1520
+ def git_driver_local_config_checks(manifest = nil, profile = "semantic-diff")
1521
+ git_driver_local_commands(manifest, profile).map do |command|
1522
+ {key: command[3], expected: command[4], argv: ["git", "config", "--local", "--get", command[3]]}
1523
+ end
1524
+ end
1525
+
1526
+ def git_driver_manifest(project_root)
1527
+ path = File.join(project_root.to_s, ".structuredmerge", "git-drivers.toml")
1528
+ return nil unless File.file?(path)
1529
+
1530
+ manifest = TomlRB.parse(File.read(path))
1531
+ validate_git_driver_manifest!(manifest)
1532
+ manifest
1533
+ rescue TomlRB::ParseError => error
1534
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: #{error.message}"
1535
+ end
1536
+
1537
+ def git_driver_manifest_profile(manifest, profile)
1538
+ return nil unless manifest
1539
+
1540
+ manifest.fetch("profiles").fetch(profile)
1541
+ end
1542
+
1543
+ def validate_git_driver_manifest!(manifest)
1544
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: root must be a mapping" unless manifest.is_a?(Hash)
1545
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: version must be 1" unless manifest["version"] == 1
1546
+
1547
+ profiles = manifest["profiles"]
1548
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: profiles must be a mapping" unless profiles.is_a?(Hash)
1549
+
1550
+ profiles.each do |profile_name, profile|
1551
+ validate_git_driver_profile!(profile_name, profile)
1552
+ end
1553
+ end
1554
+
1555
+ def validate_git_driver_profile!(profile_name, profile)
1556
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: profile name is required" if profile_name.to_s.empty?
1557
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: profile #{profile_name} must be a mapping" unless profile.is_a?(Hash)
1558
+
1559
+ Array(profile.fetch("attributes", [])).each do |entry|
1560
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: profile #{profile_name} attribute pattern is required" if entry["pattern"].to_s.empty?
1561
+ end
1562
+ Array(profile.fetch("git_config", [])).each do |entry|
1563
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: profile #{profile_name} git_config scope must be global" unless entry["scope"] == "global"
1564
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: profile #{profile_name} git_config key is required" if entry["key"].to_s.empty?
1565
+ if entry["key"].to_s.end_with?(".cachetextconv") && !profile_name.to_s.include?("textconv")
1566
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: cachetextconv requires an explicit textconv profile"
1567
+ end
1568
+ value = entry["value"].to_s
1569
+ raise Kettle::Jem::Error, "Invalid .structuredmerge/git-drivers.toml: unsafe command interpolation" if unsafe_git_driver_command_value?(value)
1570
+ end
1571
+ end
1572
+
1573
+ def unsafe_git_driver_command_value?(value)
1574
+ value.include?("$(") || value.include?("${") || value.include?("`")
1575
+ end
1576
+
1577
+ def git_driver_attribute_status(diagnostics, dry_run:)
1578
+ return "blocked" if diagnostics.any? { |diagnostic| diagnostic.fetch(:blocking, false) }
1579
+
1580
+ dry_run ? "planned" : "ready"
1581
+ end
1582
+
1583
+ def git_driver_attribute_reason(diagnostics, dry_run:, ready:)
1584
+ return "git_driver_attribute_conflict" if diagnostics.any? { |diagnostic| diagnostic.fetch(:key) == "conflicting_attributes" }
1585
+ return "dirty_managed_block" if diagnostics.any? { |diagnostic| diagnostic.fetch(:key) == "dirty_managed_block" }
1586
+
1587
+ dry_run ? "dry_run_git_driver_attributes" : ready
1588
+ end
1589
+
1590
+ def git_driver_attribute_diagnostics(project_root, updates, managed_block:)
1591
+ path = File.join(project_root.to_s, ".gitattributes")
1592
+ return [] unless File.file?(path)
1593
+
1594
+ content = File.read(path)
1595
+ diagnostics = []
1596
+ if dirty_git_attribute_managed_block?(content, managed_block)
1597
+ diagnostics << {
1598
+ key: "dirty_managed_block",
1599
+ severity: "error",
1600
+ blocking: true,
1601
+ path: ".gitattributes",
1602
+ managed_block: managed_block
1603
+ }
1604
+ end
1605
+ diagnostics.concat(conflicting_git_attribute_diagnostics(content, updates, managed_block: managed_block))
1606
+ diagnostics
1607
+ end
1608
+
1609
+ def dirty_git_attribute_managed_block?(content, managed_block)
1610
+ start_line = git_attribute_block_start(managed_block)
1611
+ end_line = git_attribute_block_end(managed_block)
1612
+ content.lines.any? { |line| line.chomp == start_line } &&
1613
+ content.lines.none? { |line| line.chomp == end_line }
1614
+ end
1615
+
1616
+ def conflicting_git_attribute_diagnostics(content, updates, managed_block:)
1617
+ lines = remove_git_attribute_managed_block(content, managed_block: managed_block).fetch(:lines)
1618
+ updates.filter_map do |update|
1619
+ conflict = lines.find { |line| git_attribute_line_conflicts?(line, update) }
1620
+ next unless conflict
1621
+
1622
+ {
1623
+ key: "conflicting_attributes",
1624
+ severity: "error",
1625
+ blocking: true,
1626
+ path: update.fetch(:path, ".gitattributes"),
1627
+ pattern: update.fetch(:pattern),
1628
+ line: conflict
1629
+ }
1630
+ end
1631
+ end
1632
+
1633
+ def git_attribute_line_conflicts?(line, update)
1634
+ parts = line.to_s.strip.split
1635
+ return false if parts.empty? || parts.first.start_with?("#")
1636
+ return false unless parts.first == update.fetch(:pattern)
1637
+
1638
+ attributes = update.fetch(:attributes)
1639
+ parts.drop(1).any? do |token|
1640
+ key, value = token.split("=", 2)
1641
+ attributes.key?(key) && attributes.fetch(key) != value
1642
+ end
1643
+ end
1644
+
1645
+ def bundled_handoff_step(project_root:, env:, run_options:)
1646
+ if Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:bootstrap_mode])
1647
+ return {
1648
+ name: "bundled_handoff",
1649
+ status: "skipped",
1650
+ reason: "bootstrap_mode"
1651
+ }
1652
+ end
1653
+
1654
+ bundle_gemfile = (env || {})["BUNDLE_GEMFILE"].to_s.strip
1655
+ project_gemfile = File.expand_path(File.join(project_root.to_s, "Gemfile"))
1656
+ if !bundle_gemfile.empty? && File.expand_path(bundle_gemfile) == project_gemfile
1657
+ return {
1658
+ name: "bundled_handoff",
1659
+ status: "already_bundled",
1660
+ bundle_gemfile: bundle_gemfile
1661
+ }
1662
+ end
1663
+
1664
+ unless bundle_includes_gem?(project_root, "kettle-jem", env: setup_command_env(project_root, env))
1665
+ return {
1666
+ name: "bundled_handoff",
1667
+ status: "skipped",
1668
+ reason: "kettle_jem_not_in_bundle"
1669
+ }
1670
+ end
1671
+
1672
+ {
1673
+ name: "bundled_handoff",
1674
+ command: bundled_handoff_command(run_options),
1675
+ status: "ready",
1676
+ reason: "ready_for_orchestration"
1677
+ }
1678
+ end
1679
+
1680
+ def bootstrap_commit_step(project_root, run_options:)
1681
+ if Kettle::Jem::DecisionPolicy.value_to_boolean((run_options || {})[:skip_commit])
1682
+ return {
1683
+ name: "bootstrap_commit",
1684
+ status: "skipped",
1685
+ reason: "skip_commit"
1686
+ }
1687
+ end
1688
+
1689
+ unless git_success?(project_root, "rev-parse", "--is-inside-work-tree")
1690
+ return {
1691
+ name: "bootstrap_commit",
1692
+ status: "unavailable",
1693
+ reason: "not_git_repository"
1694
+ }
1695
+ end
1696
+
1697
+ dirty_entries = git_output(project_root, "status", "--porcelain").lines.map(&:chomp).reject(&:empty?)
1698
+ if dirty_entries.empty?
1699
+ return {
1700
+ name: "bootstrap_commit",
1701
+ status: "clean_noop",
1702
+ dirty_entries: []
1703
+ }
1704
+ end
1705
+
1706
+ commands = []
1707
+ commands << %w[git add -A -- .]
1708
+ commands << ["git", "commit", "-m", "🎨 Template bootstrap by kettle-jem v#{Kettle::Jem::Version::VERSION}"]
1709
+ {
1710
+ name: "bootstrap_commit",
1711
+ status: "ready",
1712
+ dirty_entries: dirty_entries,
1713
+ commands: commands,
1714
+ reason: "ready_for_orchestration"
1715
+ }
1716
+ end
1717
+
1718
+ def git_success?(project_root, *args)
1719
+ _stdout, _stderr, status = Open3.capture3("git", "-C", project_root.to_s, *args)
1720
+ status.success?
1721
+ end
1722
+
1723
+ def git_output(project_root, *args)
1724
+ stdout, _stderr, status = Open3.capture3("git", "-C", project_root.to_s, *args)
1725
+ status.success? ? stdout : ""
1726
+ end
1727
+
1728
+ def handoff_argv(run_options)
1729
+ options = run_options || {}
1730
+ argv = []
1731
+ argv << "--accept-config" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:accept_config])
1732
+ argv << "--skip-commit" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:skip_commit])
1733
+ argv << "--skip-drift-check" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:skip_drift_check])
1734
+ argv << "--skip-rubocop-gradual" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:skip_rubocop_gradual])
1735
+ argv << "--skip-binstubs" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:skip_binstubs])
1736
+ argv << "--quiet" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:quiet])
1737
+ argv << "--verbose" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:verbose])
1738
+ argv << "--force" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:force])
1739
+ argv << "--accept" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:accept])
1740
+ argv << "--interactive" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:interactive])
1741
+ argv << "--dry-run" if Kettle::Jem::DecisionPolicy.value_to_boolean(options[:dry_run])
1742
+ argv.concat(value_arg("--checksums", options[:checksums]))
1743
+ argv.concat(value_arg("--failure-mode", options[:failure_mode]))
1744
+ argv.concat(value_arg("--allowed", options[:allowed]))
1745
+ argv.concat(value_arg("--hook-templates", options[:hook_templates]))
1746
+ argv.concat(value_arg("--git-drivers", options[:git_drivers]))
1747
+ argv.concat(list_arg("--only", options[:only]))
1748
+ argv.concat(list_arg("--include", options[:include]))
1749
+ argv
1750
+ end
1751
+
1752
+ def bundled_handoff_command(run_options)
1753
+ [
1754
+ "bundle",
1755
+ "exec",
1756
+ "ruby",
1757
+ "-e",
1758
+ %(load Gem.bin_path("kettle-jem", "kettle-jem")),
1759
+ "--"
1760
+ ] + handoff_argv(run_options)
1761
+ end
1762
+
1763
+ def value_arg(flag, value)
1764
+ value.to_s.strip.empty? ? [] : [flag, value.to_s]
1765
+ end
1766
+
1767
+ def list_arg(flag, value)
1768
+ values = Array(value).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?)
1769
+ values.empty? ? [] : [flag, values.join(",")]
1770
+ end
1771
+
1772
+ def bin_setup_command(project_root, quiet:)
1773
+ command = [File.join("bin", "setup")]
1774
+ command << "--quiet" if quiet && File.exist?(File.join(project_root, "bin", "setup"))
1775
+ command
1776
+ end
1777
+
1778
+ def run_command_step(name, command, project_root:, env:, quiet:, command_runner:)
1779
+ if command.first == File.join("bin", "setup") && !File.exist?(File.join(project_root, "bin", "setup"))
1780
+ return {
1781
+ name: name,
1782
+ command: command,
1783
+ status: "skipped",
1784
+ reason: "missing bin/setup"
1785
+ }
1786
+ end
1787
+
1788
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1789
+ result = command_runner.call(command, chdir: project_root, env: env, quiet: quiet)
1790
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
1791
+ success = result.fetch(:success)
1792
+ if success
1793
+ return {
1794
+ name: name,
1795
+ command: command,
1796
+ status: "succeeded",
1797
+ exitstatus: result[:exitstatus],
1798
+ duration_ms: duration_ms
1799
+ }
1800
+ end
1801
+
1802
+ recovered = retry_after_unsupported_templating_platform(
1803
+ name: name,
1804
+ command: command,
1805
+ project_root: project_root,
1806
+ env: env,
1807
+ quiet: quiet,
1808
+ command_runner: command_runner,
1809
+ failure: result
1810
+ )
1811
+ return recovered if recovered
1812
+
1813
+ raise Kettle::Jem::Error, "#{name} failed: #{command.join(" ")}\n#{result[:stderr]}"
1814
+ end
1815
+
1816
+ def retry_after_unsupported_templating_platform(name:, command:, project_root:, env:, quiet:, command_runner:, failure:)
1817
+ platform = unsupported_templating_platform(failure[:stderr], project_root)
1818
+ return unless platform
1819
+
1820
+ recovery_command = ["bundle", "lock", "--remove-platform=#{platform}"]
1821
+ recovery = command_runner.call(recovery_command, chdir: project_root, env: env, quiet: quiet)
1822
+ return unless recovery.fetch(:success)
1823
+
1824
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1825
+ retried = command_runner.call(command, chdir: project_root, env: env, quiet: quiet)
1826
+ return unless retried.fetch(:success)
1827
+
1828
+ {
1829
+ name: name,
1830
+ command: command,
1831
+ status: "succeeded",
1832
+ exitstatus: retried[:exitstatus],
1833
+ duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3),
1834
+ recovered: true,
1835
+ recovery: {
1836
+ command: recovery_command,
1837
+ platform: platform,
1838
+ status: "succeeded",
1839
+ changed_files: ["Gemfile.lock"]
1840
+ }
1841
+ }
1842
+ end
1843
+
1844
+ def unsupported_templating_platform(stderr, project_root)
1845
+ # Bundler exposes this compatibility failure only as stderr text; match
1846
+ # its exact quoted diagnostic rather than guessing from lockfile content.
1847
+ match = /Could not find gem 'tree_sitter_language_pack' with platform '([^']+)'/.match(stderr.to_s)
1848
+ return unless match
1849
+
1850
+ lock_path = File.join(project_root.to_s, "Gemfile.lock")
1851
+ return unless File.file?(lock_path)
1852
+
1853
+ platforms = Bundler::LockfileParser.new(Bundler.read_file(lock_path)).platforms.map(&:to_s)
1854
+ platform = match[1]
1855
+ platforms.include?(platform) ? platform : nil
1856
+ rescue Bundler::LockfileError
1857
+ nil
1858
+ end
1859
+
1860
+ def execute_ready_command_step(step, project_root:, env:, quiet:, command_runner:)
1861
+ return step unless step.fetch(:status) == "ready"
1862
+
1863
+ command_env = step.fetch(:env, env)
1864
+ result = run_ready_step_command(step.fetch(:name), step.fetch(:command), project_root: project_root, env: command_env, quiet: quiet, command_runner: command_runner)
1865
+ step.merge(
1866
+ status: "succeeded",
1867
+ exitstatus: result[:exitstatus],
1868
+ duration_ms: result.fetch(:duration_ms),
1869
+ reason: "executed"
1870
+ ).tap do |report|
1871
+ report[:attempts] = result.fetch(:attempts) if result.fetch(:attempts) > 1
1872
+ end
1873
+ end
1874
+
1875
+ def execute_ready_commands_step(step, project_root:, env:, quiet:, command_runner:)
1876
+ return step unless step.fetch(:status) == "ready"
1877
+
1878
+ if step.fetch(:name) == "bootstrap_commit"
1879
+ return execute_bootstrap_commit_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1880
+ end
1881
+
1882
+ execute_unlocked_ready_commands_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
1883
+ end
1884
+
1885
+ def execute_bootstrap_commit_step(step, project_root:, env:, quiet:, command_runner:)
1886
+ with_git_operation_lock(env, metadata_key: :git_commit_lock) do
1887
+ execute_unlocked_ready_commands_step(
1888
+ step,
1889
+ project_root: project_root,
1890
+ env: env,
1891
+ quiet: quiet,
1892
+ command_runner: command_runner
1893
+ )
1894
+ end
1895
+ end
1896
+
1897
+ def execute_unlocked_ready_commands_step(step, project_root:, env:, quiet:, command_runner:)
1898
+ if step.fetch(:name) == "bootstrap_commit"
1899
+ dirty_entries = git_output(project_root, "status", "--porcelain").lines.map(&:chomp).reject(&:empty?)
1900
+ if dirty_entries.empty?
1901
+ return step.merge(
1902
+ status: "clean_noop",
1903
+ dirty_entries: [],
1904
+ reason: "clean_before_execution"
1905
+ )
1906
+ end
1907
+ end
1908
+
1909
+ command_env = step.fetch(:env, env)
1910
+ results = step.fetch(:commands).map do |command|
1911
+ result = run_ready_step_command(step.fetch(:name), command, project_root: project_root, env: command_env, quiet: quiet, command_runner: command_runner)
1912
+ {
1913
+ command: command,
1914
+ exitstatus: result[:exitstatus],
1915
+ duration_ms: result.fetch(:duration_ms)
1916
+ }.tap do |report|
1917
+ report[:attempts] = result.fetch(:attempts) if result.fetch(:attempts) > 1
1918
+ end
1919
+ end
1920
+ step.merge(
1921
+ status: "succeeded",
1922
+ command_results: results,
1923
+ duration_ms: results.sum { |result| result.fetch(:duration_ms, 0).to_f }.round(3),
1924
+ reason: "executed"
1925
+ )
1926
+ end
1927
+
1928
+ def run_ready_step_command(step_name, command, project_root:, env:, quiet:, command_runner:)
1929
+ attempts = git_operation_step_name?(step_name) ? GIT_OPERATION_LOCK_RETRY_ATTEMPTS : 1
1930
+ attempt = 0
1931
+ last_result = nil
1932
+ last_duration_ms = 0.0
1933
+ loop do
1934
+ attempt += 1
1935
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1936
+ last_result = command_runner.call(command, chdir: project_root, env: env, quiet: quiet)
1937
+ last_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
1938
+ if last_result.fetch(:success)
1939
+ return {
1940
+ success: true,
1941
+ exitstatus: last_result[:exitstatus],
1942
+ duration_ms: last_duration_ms,
1943
+ attempts: attempt
1944
+ }
1945
+ end
1946
+
1947
+ break unless attempt < attempts && git_lock_conflict?(last_result[:stderr])
1948
+
1949
+ sleep(GIT_OPERATION_LOCK_RETRY_SLEEP_SECONDS * attempt)
1950
+ end
1951
+
1952
+ raise Kettle::Jem::Error, "#{step_name} failed: #{command.join(" ")}\n#{last_result[:stderr]}"
1953
+ end
1954
+
1955
+ def git_operation_step_name?(step_name)
1956
+ %w[bootstrap_commit git_drivers].include?(step_name.to_s)
1957
+ end
1958
+
1959
+ def git_lock_conflict?(stderr)
1960
+ text = stderr.to_s
1961
+ text.include?("could not lock config file") ||
1962
+ text.include?("config.lock") ||
1963
+ text.include?("index.lock") ||
1964
+ text.include?("Unable to create") && text.include?(".lock")
1965
+ end
1966
+
1967
+ def git_operation_lock_path(env)
1968
+ GIT_OPERATION_LOCK_ENV_KEYS.each do |key|
1969
+ value = env.fetch(key, nil).to_s
1970
+ return value unless value.empty?
1971
+ end
1972
+ nil
1973
+ end
1974
+
1975
+ def with_git_operation_lock(env, metadata_key:)
1976
+ lock_path = git_operation_lock_path(env || {})
1977
+ return yield if lock_path.to_s.empty?
1978
+
1979
+ FileUtils.mkdir_p(File.dirname(lock_path))
1980
+ File.open(lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
1981
+ lock.flock(File::LOCK_EX)
1982
+ yield.merge(metadata_key => lock_path)
1983
+ ensure
1984
+ lock&.flock(File::LOCK_UN)
1985
+ end
1986
+ end
1987
+
1988
+ def execute_hook_templates_step(step, project_root:, env:, quiet:, command_runner:)
1989
+ return step unless step.fetch(:status) == "ready"
1990
+
1991
+ step.fetch(:chmod_paths, []).each do |relative_path|
1992
+ path = File.join(project_root.to_s, relative_path)
1993
+ FileUtils.chmod(0o755, path) if File.file?(path)
1994
+ end
1995
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1996
+ result = command_runner.call(step.fetch(:command), chdir: project_root, env: env, quiet: quiet)
1997
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000.0).round(3)
1998
+ unless result.fetch(:success)
1999
+ raise Kettle::Jem::Error, "hook_templates failed: #{step.fetch(:command).join(" ")}\n#{result[:stderr]}"
2000
+ end
2001
+ step.merge(
2002
+ status: "succeeded",
2003
+ exitstatus: result[:exitstatus],
2004
+ duration_ms: duration_ms,
2005
+ reason: "executed"
2006
+ )
2007
+ end
2008
+
2009
+ def execute_git_drivers_step(step, project_root:, env:, quiet:, command_runner:)
2010
+ return step unless step.fetch(:status) == "ready"
2011
+
2012
+ if step.fetch(:mode) == "check"
2013
+ return execute_git_drivers_check_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
2014
+ end
2015
+
2016
+ with_git_operation_lock(env, metadata_key: :git_lock) do
2017
+ execute_unlocked_git_drivers_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
2018
+ end
2019
+ end
2020
+
2021
+ def execute_unlocked_git_drivers_step(step, project_root:, env:, quiet:, command_runner:)
2022
+ changed_files = []
2023
+ if step.fetch(:mode) == "include-file"
2024
+ include_path = File.join(project_root.to_s, step.fetch(:include_file))
2025
+ FileUtils.mkdir_p(File.dirname(include_path))
2026
+ before = File.file?(include_path) ? File.read(include_path) : ""
2027
+ after = render_git_driver_include_config(step.fetch(:config_entries))
2028
+ if before != after
2029
+ File.write(include_path, after)
2030
+ changed_files << step.fetch(:include_file)
2031
+ end
2032
+ end
2033
+ Array(step[:attribute_removals]).each do |removal|
2034
+ path = File.join(project_root.to_s, removal.fetch(:path))
2035
+ next unless File.file?(path)
2036
+
2037
+ before = File.read(path)
2038
+ after = remove_git_attribute_managed_block(before, managed_block: removal.fetch(:managed_block)).fetch(:lines).join("\n")
2039
+ after = "#{after}\n" unless after.empty?
2040
+ next if after == before
2041
+
2042
+ File.write(path, after)
2043
+ changed_files << removal.fetch(:path)
2044
+ end
2045
+
2046
+ unless Array(step[:attribute_updates]).empty?
2047
+ path = File.join(project_root.to_s, ".gitattributes")
2048
+ before = File.file?(path) ? File.read(path) : ""
2049
+ after = render_git_attributes(before, step.fetch(:attribute_updates), managed_block: step.fetch(:managed_block))
2050
+ if after != before
2051
+ File.write(path, after)
2052
+ changed_files << ".gitattributes"
2053
+ end
2054
+ end
2055
+
2056
+ command_step = execute_ready_commands_step(step, project_root: project_root, env: env, quiet: quiet, command_runner: command_runner)
2057
+ command_step.merge(
2058
+ status: "succeeded",
2059
+ changed_files: changed_files.uniq,
2060
+ reason: "executed"
2061
+ )
2062
+ end
2063
+
2064
+ def execute_git_drivers_check_step(step, project_root:, env:, quiet:, command_runner:)
2065
+ missing = []
2066
+ content = File.file?(File.join(project_root.to_s, ".gitattributes")) ? File.read(File.join(project_root.to_s, ".gitattributes")) : ""
2067
+ Array(step[:attribute_updates]).each do |update|
2068
+ next if content.include?(render_git_attribute_line(update))
2069
+
2070
+ missing << {kind: "attributes", path: update.fetch(:path), pattern: update.fetch(:pattern)}
2071
+ end
2072
+ Array(step[:config_checks]).each do |check|
2073
+ result = command_runner.call(check.fetch(:argv), chdir: project_root, env: env, quiet: quiet)
2074
+ next if result.fetch(:success) && result.fetch(:stdout).to_s.strip == check.fetch(:expected)
2075
+
2076
+ missing << {kind: "local_config", key: check.fetch(:key)}
2077
+ end
2078
+ status = missing.empty? ? "succeeded" : "failed"
2079
+ report = step.merge(status: status, ok: missing.empty?, missing: missing, reason: "checked")
2080
+ return report if missing.empty?
2081
+
2082
+ raise Kettle::Jem::Error, "git_drivers check failed: #{missing.map { |entry| entry.fetch(:kind) }.uniq.join(", ")} missing"
2083
+ end
2084
+
2085
+ def render_git_driver_include_config(config_entries)
2086
+ lines = ["# Generated by kettle-jem; do not commit this file."]
2087
+ Array(config_entries).each do |entry|
2088
+ section, name = entry.fetch(:key).split(".", 2)
2089
+ lines << ""
2090
+ lines << "[#{section} \"#{name.split(".").first}\"]"
2091
+ lines << "\t#{name.split(".").drop(1).join(".")} = #{entry.fetch(:value).inspect}"
2092
+ end
2093
+ "#{lines.join("\n")}\n"
2094
+ end
2095
+
2096
+ def render_git_attributes(content, updates, managed_block:)
2097
+ unmanaged_lines = remove_git_attribute_managed_block(content, managed_block: managed_block).fetch(:lines)
2098
+ rendered_lines = unmanaged_lines.reject { |line| line.empty? && unmanaged_lines.last == line }
2099
+ rendered_lines << git_attribute_block_start(managed_block)
2100
+ updates.each { |update| rendered_lines << render_git_attribute_line(update) }
2101
+ rendered_lines << git_attribute_block_end(managed_block)
2102
+ "#{rendered_lines.join("\n")}\n"
2103
+ end
2104
+
2105
+ def remove_git_attribute_managed_block(content, managed_block:)
2106
+ start_line = git_attribute_block_start(managed_block)
2107
+ end_line = git_attribute_block_end(managed_block)
2108
+ skipping = false
2109
+ removed = false
2110
+ lines = []
2111
+ content.lines(chomp: true).each do |line|
2112
+ if line == start_line
2113
+ skipping = true
2114
+ removed = true
2115
+ next
2116
+ end
2117
+ if skipping
2118
+ skipping = false if line == end_line
2119
+ next
2120
+ end
2121
+ lines << line
2122
+ end
2123
+ {lines: lines, removed: removed}
2124
+ end
2125
+
2126
+ def render_git_attribute_line(update)
2127
+ attributes = update.fetch(:attributes).map { |key, value| "#{key}=#{value}" }.join(" ")
2128
+ "#{update.fetch(:pattern)} #{attributes}"
2129
+ end
2130
+
2131
+ def git_attribute_block_start(managed_block)
2132
+ "# <<#{managed_block}>> do not edit below this line"
2133
+ end
2134
+
2135
+ def git_attribute_block_end(managed_block)
2136
+ "# <</#{managed_block}>>"
2137
+ end
2138
+
2139
+ def run_system_command(command, chdir:, env:, quiet:)
2140
+ command_env = quiet ? quiet_command_env(env) : (env || {})
2141
+ stdout, stderr, status = Open3.capture3(command_env, *quiet_command(command, quiet: quiet), chdir: chdir)
2142
+ $stdout.print(stdout) if !quiet && !stdout.empty?
2143
+ $stderr.print(stderr) if !quiet && !stderr.empty?
2144
+ {
2145
+ success: status.success?,
2146
+ exitstatus: status.exitstatus,
2147
+ stdout: stdout,
2148
+ stderr: stderr
2149
+ }
2150
+ end
2151
+
2152
+ def quiet_command(command, quiet:)
2153
+ argv = command.map(&:to_s)
2154
+ return argv unless quiet
2155
+ return argv unless argv.first == "bundle"
2156
+ return argv if argv.include?("--quiet")
2157
+
2158
+ subcommand = argv[1]
2159
+ return [*argv, "--quiet"] if %w[install update].include?(subcommand)
2160
+
2161
+ argv
2162
+ end
2163
+
2164
+ def quiet_command_env(env)
2165
+ (env || {}).to_h.merge(
2166
+ "DEBUG" => "false",
2167
+ "KETTLE_JEM_DEBUG" => "false",
2168
+ "KETTLE_DEV_DEBUG" => "false",
2169
+ "BUNDLE_IGNORE_MESSAGES" => "true",
2170
+ "BUNDLE_SILENCE_DEPRECATIONS" => "true",
2171
+ "BUNDLE_SILENCE_ROOT_WARNING" => "true",
2172
+ "BUNDLE_VERBOSE" => "false"
2173
+ )
2174
+ end
2175
+ end
2176
+ end
2177
+ end
2178
+ end