kettle-jem 7.0.0 → 7.1.1

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