kettle-family 1.2.91 → 1.2.92

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e63a81da6b871ee1c27d813b23f7840e7c0e0a577e3a992076922ff2750574b3
4
- data.tar.gz: 859158cd1aa7b293126edb628f02eb8bc641a0fcd4797a6f097af9fe46a9ef27
3
+ metadata.gz: c1646ca3577c370624800077609c69c0decd4a50ad6924e82cfe3c050c1757df
4
+ data.tar.gz: 361e0ac1cd39a97b442d3296635055b5bd34c24ced7d3944cec4a0984df6babb
5
5
  SHA512:
6
- metadata.gz: ee75302308856eb21755bfdc3a598fe9e0d3992b4a493c84825e891dbd76f514f976549fde093de80bfe55d822567d36cba2375a666c1ca2fc6b4a9844f8ae5c
7
- data.tar.gz: 3845dff951350adc5177e8ea3bb4ce9b80a155a4a6fbb2b05618acb2363e77e9e8ce158129521f03ca8d02816b8bdfd1f1c5ec80507c982a0546e95314815068
6
+ metadata.gz: fa89492878000e8fa0b08b244b95a432ddb96e11130da0d4655087580b63260242b00f28a3f76af32c3a9dc8d9d2830f815f22f960f5c835b4b3af851196b510
7
+ data.tar.gz: 1a801e2a97fdb2726e8ac282f88686e87aff42b680b54e2798b09bd70aa4e6be3678e9bf45625ab057a3d2210e99cd59eeeba31fd2761880f27cabc2b53a1566
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,17 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.2.92] - 2026-09-07
34
+
35
+ - TAG: [v1.2.92][1.2.92t]
36
+ - COVERAGE: 93.52% -- 6144/6570 lines in 35 files
37
+ - BRANCH COVERAGE: 77.98% -- 2490/3193 branches in 35 files
38
+ - 32.41% documented
39
+
40
+ ### Fixed
41
+
42
+ - Preserve pre-existing monorepo member changes while templating in parallel worktrees, reject concurrent member edits without overwriting them, and count only fully materialized members as successful.
43
+
33
44
  ## [1.2.91] - 2026-09-06
34
45
 
35
46
  - TAG: [v1.2.91][1.2.91t]
@@ -2605,7 +2616,9 @@ Please file a bug if you notice a violation of semantic versioning.
2605
2616
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2606
2617
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2607
2618
 
2608
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.91...HEAD
2619
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.92...HEAD
2620
+ [1.2.92]: https://github.com/kettle-dev/kettle-family/compare/v1.2.91...v1.2.92
2621
+ [1.2.92t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.92
2609
2622
  [1.2.91]: https://github.com/kettle-dev/kettle-family/compare/v1.2.90...v1.2.91
2610
2623
  [1.2.91t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.91
2611
2624
  [1.2.90]: https://github.com/kettle-dev/kettle-family/compare/v1.2.89...v1.2.90
data/README.md CHANGED
@@ -281,6 +281,22 @@ canonical development lockfile and CI checkout share that path graph. In both
281
281
  cases, release normalization rejects every path outside the configured allow
282
282
  list.
283
283
 
284
+ ### Template concurrency
285
+
286
+ Executed `kettle-jem` templating for monorepo members uses detached worktrees
287
+ when a dependency wave contains multiple ready members and the worker budget is
288
+ greater than one. Before a worker starts, Family copies that member's current
289
+ tracked and untracked state into its worktree. This preserves development
290
+ lockfile changes produced by local dependency updates instead of assuming that
291
+ the primary checkout matches `HEAD`.
292
+
293
+ After the worker succeeds, Family verifies that the same member in the primary
294
+ checkout has not changed since the snapshot. It then materializes the worker's
295
+ final member-scoped state and serializes the normal template commit. Changes
296
+ outside the member root are rejected, as are concurrent edits to that member;
297
+ in either case the primary member is left untouched. Template summaries count
298
+ a member only after worktree materialization and any required commit succeed.
299
+
284
300
  ### Test concurrency
285
301
 
286
302
  `kettle-family test --execute` runs independent sibling repositories in
@@ -1121,7 +1137,7 @@ Thanks for RTFM. ☺️
1121
1137
  [📌gitmoji]: https://gitmoji.dev
1122
1138
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1123
1139
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1124
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.528-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1140
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.570-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1125
1141
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1126
1142
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1127
1143
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -332,11 +332,16 @@ module Kettle
332
332
  template_results = results.select { |result| result.phase == "template" }
333
333
  return if template_results.empty?
334
334
 
335
+ successful_names = summary_succeeded
336
+ template_results.select! do |result|
337
+ !result.ok? || successful_names.include?(result.member_name)
338
+ end
339
+
335
340
  changed_files = template_results.sum { |result| template_changed_file_count(result) }
336
341
  outcome_counts = template_results.map { |result| template_file_outcomes(result) }
337
342
  lines << "template summary:"
338
- lines << " #{template_results.count(&:ok?)}/#{selected_members.length} members ok"
339
- if outcome_counts.all?
343
+ lines << " #{successful_names.length}/#{selected_members.length} members ok"
344
+ if outcome_counts.any? && outcome_counts.all?
340
345
  lines << " #{outcome_counts.sum { |outcomes| outcomes.fetch(:checksum_hits) }} checksum hits"
341
346
  protected = outcome_counts.sum { |outcomes| outcomes.fetch(:checksum_protected) }
342
347
  lines << " #{protected} checksum-protected changes" if protected.positive?
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "1.2.91"
8
+ VERSION = "1.2.92"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "io/console"
4
4
  require "fileutils"
5
+ require "digest"
5
6
  require "json"
6
7
  require "etc"
7
8
  require "open3"
@@ -1047,8 +1048,12 @@ module Kettle
1047
1048
  worktree_root: worktree_root
1048
1049
  }
1049
1050
  entries << entry
1051
+ prepare_monorepo_template_worktree(entry)
1050
1052
  results.concat(worktree_mise_trust_results(entry, runner: runner, phase: "template_member_worktree_mise_trust"))
1051
1053
  break unless results.last.ok?
1054
+ rescue => error
1055
+ results << template_worktree_failure_result(member, error, phase: "template_member_worktree_seed")
1056
+ break
1052
1057
  end
1053
1058
  [entries, results]
1054
1059
  end
@@ -1102,39 +1107,64 @@ module Kettle
1102
1107
  def materialize_monorepo_template_changes(entry)
1103
1108
  original_member = entry.fetch(:original_member)
1104
1109
  worktree_root = entry.fetch(:worktree_root)
1105
- relative_root = Pathname.new(original_member.root).relative_path_from(Pathname.new(config.root)).to_s
1110
+ relative_root = monorepo_template_relative_root(original_member)
1106
1111
  paths = monorepo_template_worktree_changed_paths(worktree_root)
1107
- return template_worktree_failure_result(original_member, paths) if paths.is_a?(String)
1112
+ return template_worktree_failure_result(original_member, paths, phase: "template_worktree_materialize") if paths.is_a?(String)
1108
1113
 
1109
1114
  invalid_paths = paths.reject { |path| path == relative_root || path.start_with?("#{relative_root}/") }
1110
1115
  unless invalid_paths.empty?
1111
1116
  return template_worktree_failure_result(
1112
1117
  original_member,
1113
- "isolated template worker modified shared path(s): #{invalid_paths.sort.join(", ")}; only #{relative_root}/ may be materialized"
1118
+ "isolated template worker modified shared path(s): #{invalid_paths.sort.join(", ")}; only #{relative_root}/ may be materialized",
1119
+ phase: "template_worktree_materialize"
1114
1120
  )
1115
1121
  end
1116
1122
 
1117
- tracked_paths = monorepo_template_worktree_tracked_paths(worktree_root)
1118
- return template_worktree_failure_result(original_member, tracked_paths) if tracked_paths.is_a?(String)
1119
-
1120
- runner = command_runner
1121
- control_member = monorepo_template_control_member.dup
1122
- control_member.name = original_member.name
1123
- command = if tracked_paths.empty?
1124
- ["true"]
1125
- else
1126
- source = Shellwords.escape(worktree_root)
1127
- scope = Shellwords.escape(relative_root)
1128
- ["sh", "-lc", "git -C #{source} diff --binary HEAD -- #{scope} | git apply --whitespace=nowarn"]
1123
+ initial_state = entry.fetch(:primary_member_state)
1124
+ current_state = monorepo_template_member_state(config.root, relative_root)
1125
+ changed_primary_paths = changed_monorepo_template_state_paths(initial_state, current_state)
1126
+ unless changed_primary_paths.empty?
1127
+ return template_worktree_failure_result(
1128
+ original_member,
1129
+ "primary member changed while isolated template worker ran: #{changed_primary_paths.join(", ")}",
1130
+ phase: "template_worktree_materialize"
1131
+ )
1129
1132
  end
1130
- result = runner.call(member: control_member, phase: "template_worktree_materialize", command: command)
1131
- return result unless result.ok?
1132
1133
 
1133
- copy_monorepo_template_untracked_paths(worktree_root, config.root, relative_root)
1134
- result.stdout = "#{paths.length} path(s) materialized from isolated worktree"
1135
- result
1134
+ materialized_paths = (entry.fetch(:primary_changed_paths) + paths).uniq.sort
1135
+ sync_monorepo_template_paths(worktree_root, config.root, materialized_paths)
1136
+ template_worktree_success_result(
1137
+ original_member,
1138
+ phase: "template_worktree_materialize",
1139
+ stdout: "#{materialized_paths.length} path(s) materialized from isolated worktree"
1140
+ )
1136
1141
  rescue => error
1137
- template_worktree_failure_result(original_member, error)
1142
+ template_worktree_failure_result(original_member, error, phase: "template_worktree_materialize")
1143
+ end
1144
+
1145
+ def prepare_monorepo_template_worktree(entry)
1146
+ original_member = entry.fetch(:original_member)
1147
+ relative_root = monorepo_template_relative_root(original_member)
1148
+ changed_paths = monorepo_template_worktree_changed_paths(config.root)
1149
+ raise Error, changed_paths if changed_paths.is_a?(String)
1150
+
1151
+ member_changed_paths = changed_paths.select do |path|
1152
+ path == relative_root || path.start_with?("#{relative_root}/")
1153
+ end
1154
+ sync_monorepo_template_paths(config.root, entry.fetch(:worktree_root), member_changed_paths)
1155
+ primary_state = monorepo_template_member_state(config.root, relative_root)
1156
+ worktree_state = monorepo_template_member_state(entry.fetch(:worktree_root), relative_root)
1157
+ unless primary_state == worktree_state
1158
+ changed = changed_monorepo_template_state_paths(primary_state, worktree_state)
1159
+ raise Error, "isolated template worker seed differs from primary member: #{changed.join(", ")}"
1160
+ end
1161
+
1162
+ entry[:primary_changed_paths] = member_changed_paths
1163
+ entry[:primary_member_state] = primary_state
1164
+ end
1165
+
1166
+ def monorepo_template_relative_root(member)
1167
+ Pathname.new(member.root).relative_path_from(Pathname.new(config.root)).to_s
1138
1168
  end
1139
1169
 
1140
1170
  def monorepo_template_worktree_changed_paths(worktree_root)
@@ -1155,6 +1185,28 @@ module Kettle
1155
1185
  git_worktree_paths(worktree_root, %w[ls-files --others --exclude-standard -z])
1156
1186
  end
1157
1187
 
1188
+ def monorepo_template_member_state(root, relative_root)
1189
+ paths = git_worktree_paths(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", relative_root])
1190
+ raise Error, paths if paths.is_a?(String)
1191
+
1192
+ paths.to_h { |path| [path, monorepo_template_path_signature(root, path)] }
1193
+ end
1194
+
1195
+ def monorepo_template_path_signature(root, path)
1196
+ full_path = File.join(root, path)
1197
+ stat = File.lstat(full_path)
1198
+ return ["symlink", File.readlink(full_path)] if stat.symlink?
1199
+ return ["file", stat.mode & 0o777, Digest::SHA256.file(full_path).hexdigest] if stat.file?
1200
+
1201
+ [stat.ftype, stat.mode & 0o777]
1202
+ rescue Errno::ENOENT
1203
+ ["missing"]
1204
+ end
1205
+
1206
+ def changed_monorepo_template_state_paths(before, after)
1207
+ (before.keys | after.keys).select { |path| before[path] != after[path] }.sort
1208
+ end
1209
+
1158
1210
  def git_worktree_paths(worktree_root, arguments)
1159
1211
  stdout, stderr, status = Open3.capture3("git", *arguments, chdir: worktree_root)
1160
1212
  return stdout.split("\0").reject(&:empty?) if status.success?
@@ -1162,14 +1214,19 @@ module Kettle
1162
1214
  "git #{arguments.join(" ")} failed in #{worktree_root}: #{stderr.strip}"
1163
1215
  end
1164
1216
 
1165
- def copy_monorepo_template_untracked_paths(worktree_root, primary_root, relative_root)
1166
- monorepo_template_worktree_untracked_paths(worktree_root).each do |path|
1167
- next unless path == relative_root || path.start_with?("#{relative_root}/")
1217
+ def sync_monorepo_template_paths(source_root, destination_root, paths)
1218
+ paths.each do |path|
1219
+ source = File.join(source_root, path)
1220
+ destination = File.join(destination_root, path)
1221
+ FileUtils.rm_rf(destination)
1222
+ next unless File.exist?(source) || File.symlink?(source)
1168
1223
 
1169
- source = File.join(worktree_root, path)
1170
- destination = File.join(primary_root, path)
1171
1224
  FileUtils.mkdir_p(File.dirname(destination))
1172
- FileUtils.cp_r(source, destination, preserve: true)
1225
+ if File.symlink?(source)
1226
+ File.symlink(File.readlink(source), destination)
1227
+ else
1228
+ FileUtils.cp_r(source, destination, preserve: true)
1229
+ end
1173
1230
  end
1174
1231
  end
1175
1232
 
@@ -1184,12 +1241,28 @@ module Kettle
1184
1241
  end
1185
1242
  end
1186
1243
 
1187
- def template_worktree_failure_result(member, error)
1244
+ def template_worktree_success_result(member, phase:, stdout:)
1245
+ CommandResult.new(
1246
+ member_name: member.name,
1247
+ phase: phase,
1248
+ command: ["internal", phase.tr("_", "-")],
1249
+ workdir: member.root,
1250
+ status: 0,
1251
+ success: true,
1252
+ stdout: stdout,
1253
+ stderr: "",
1254
+ elapsed_seconds: 0.0,
1255
+ skipped: false,
1256
+ reason: nil
1257
+ )
1258
+ end
1259
+
1260
+ def template_worktree_failure_result(member, error, phase: "template_member_worktree")
1188
1261
  message = error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
1189
1262
  CommandResult.new(
1190
1263
  member_name: member.name,
1191
- phase: "template_member_worktree",
1192
- command: ["internal", "template-member-worktree"],
1264
+ phase: phase,
1265
+ command: ["internal", phase.tr("_", "-")],
1193
1266
  workdir: member.root,
1194
1267
  status: 1,
1195
1268
  success: false,
@@ -4348,13 +4421,25 @@ module Kettle
4348
4421
  def emit_template_progress_summary(results, progress:)
4349
4422
  return unless progress
4350
4423
 
4351
- template_results = results.select { |result| result.phase == "template" }
4424
+ successful_names = successful_template_member_names(results)
4425
+ template_results = results.select do |result|
4426
+ result.phase == "template" && (!result.ok? || successful_names.include?(result.member_name))
4427
+ end
4352
4428
  changed_files = template_results.sum { |result| template_changed_file_count(result) }
4353
4429
  outcome_counts = template_results.map { |result| template_file_outcomes(result) }
4354
4430
  progress.stop
4355
4431
  progress.summary(template_progress_summary_label(template_results, changed_files, outcome_counts))
4356
4432
  end
4357
4433
 
4434
+ def successful_template_member_names(results)
4435
+ results.group_by(&:member_name).filter_map do |member_name, member_results|
4436
+ next unless member_results.any? { |result| result.phase == "template" }
4437
+ next unless member_results.all?(&:ok?)
4438
+
4439
+ member_name
4440
+ end
4441
+ end
4442
+
4358
4443
  def emit_release_progress_summary(results, progress:)
4359
4444
  return unless progress
4360
4445
 
@@ -4815,7 +4900,7 @@ module Kettle
4815
4900
 
4816
4901
  def template_progress_summary_label(template_results, changed_files, outcome_counts)
4817
4902
  label = "template summary: #{template_results.count(&:ok?)}/#{members.length} members ok"
4818
- return "#{label}, #{template_summary_label(changed_files)}" unless outcome_counts.all?
4903
+ return "#{label}, #{template_summary_label(changed_files)}" if outcome_counts.empty? || !outcome_counts.all?
4819
4904
 
4820
4905
  totals = outcome_counts.each_with_object({checksum_hits: 0, checksum_protected: 0, unchanged: 0}) do |outcomes, memo|
4821
4906
  memo[:checksum_hits] += outcomes.fetch(:checksum_hits)
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-family
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.91
4
+ version: 1.2.92
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -443,10 +443,10 @@ licenses:
443
443
  - AGPL-3.0-only
444
444
  metadata:
445
445
  homepage_uri: https://kettle-family.galtzo.com
446
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.91
447
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.91/CHANGELOG.md
446
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.92
447
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.92/CHANGELOG.md
448
448
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
449
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.91
449
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.92
450
450
  funding_uri: https://github.com/sponsors/pboling
451
451
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
452
452
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file