kettle-family 1.2.89 → 1.2.90

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: 82f0fe9d1ff1a9570736167dbad614ce55a2929c01da9cee1cfad7c8e9bae9d8
4
- data.tar.gz: e3bbc4d7236b5ec4a7874e5a6304b9c9e02f1e3e726a37a361b5e97d8aed6d1e
3
+ metadata.gz: fd4de333d6aa3b2bc027ec582b2bcc0b890d61b20a0a57c70bcf25196b2f9c32
4
+ data.tar.gz: 67754f998efad13f47f00f0e07222a17e9413c890b2cee1097ffbccd1114801d
5
5
  SHA512:
6
- metadata.gz: a8e8d571b682158386d7d4320941a5b77eb7b29d99f5653142126e5cb771c8deeb577ca36d0959b77f9de91471f9726a1c08ac95f8b525aa9ce6edef15ece787
7
- data.tar.gz: 0d91b201b66f7c31ee41f80b0e09346f8d228d977c2730e0bbcbef7aa0ddb31d7b9f71032b248c24881f9bc987dd1967dcc0a85977a9d2a12eced9ab090aae7a
6
+ metadata.gz: 7238680a28fa74944856d3ebe514193ac070df02fa25b319e28090bbb18f8f6a0f989ccf0d73e84f485d5e7d446de692440304dd0e99cf79b9834f4ba1212143
7
+ data.tar.gz: 75c99992e6539a9aa9e37f6ad5ea45afa0692bd9214f10df75f9c604f56af1799099e4cb1fa80b47bd9e843fa8a130103dffae010b14eb8e8f64936834447c35
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,25 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.2.90] - 2026-09-06
34
+
35
+ - TAG: [v1.2.90][1.2.90t]
36
+ - COVERAGE: 93.13% -- 6048/6494 lines in 35 files
37
+ - BRANCH COVERAGE: 76.05% -- 2407/3165 branches in 35 files
38
+ - 32.41% documented
39
+
40
+ ### Added
41
+
42
+ - Run sibling test workflows concurrently with dynamic TurboTests2 process budgets.
43
+
44
+ - Support aggregate and disposable-worktree monorepo test execution modes.
45
+
46
+ ### Fixed
47
+
48
+ - Template ready monorepo sibling gems concurrently in isolated worktrees while materializing only member-scoped changes.
49
+
50
+ - Derive family command concurrency from the executing CPU count and pass template wave width to nested tools.
51
+
33
52
  ## [1.2.89] - 2026-09-06
34
53
 
35
54
  - TAG: [v1.2.89][1.2.89t]
@@ -2573,7 +2592,9 @@ Please file a bug if you notice a violation of semantic versioning.
2573
2592
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2574
2593
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2575
2594
 
2576
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.89...HEAD
2595
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.2.90...HEAD
2596
+ [1.2.90]: https://github.com/kettle-dev/kettle-family/compare/v1.2.89...v1.2.90
2597
+ [1.2.90t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.90
2577
2598
  [1.2.89]: https://github.com/kettle-dev/kettle-family/compare/v1.2.88...v1.2.89
2578
2599
  [1.2.89t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.2.89
2579
2600
  [1.2.88]: https://github.com/kettle-dev/kettle-family/compare/v1.2.87...v1.2.88
data/README.md CHANGED
@@ -281,6 +281,37 @@ 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
+ ### Test concurrency
285
+
286
+ `kettle-family test --execute` runs independent sibling repositories in
287
+ parallel. Its default outer width is half of the CPUs detected on the current
288
+ machine, capped by the number of selected members; `--jobs` and `test.jobs`
289
+ override that width. The same budget is passed to member `kettle-release`
290
+ commands, because their local test phase uses the same process pool.
291
+
292
+ Family sets `TURBO_TESTS2_MAX_PROCESSES` for each active wave. This is an
293
+ inclusive ceiling for TurboTests2: it includes the member's primary test
294
+ process, not merely additional workers. Given `C` detected CPUs and `W` active
295
+ Family wave slots, the ceiling is `min(floor(C / 2), 1 + floor((C - W) / W))`.
296
+ An explicit `turbo_tests2 --count`/`--workers` value, or an explicitly supplied
297
+ `TURBO_TESTS2_MAX_PROCESSES`, takes precedence.
298
+
299
+ Monorepos default to `test.monorepo_mode: aggregate`: one selected member runs
300
+ the shared root suite and the remaining selected subgems are reported as
301
+ skipped. Set `test.aggregate_member` to choose that owner when it is selected.
302
+ For monorepos with genuinely member-scoped suites, opt in to disposable
303
+ worktrees instead:
304
+
305
+ ```yaml
306
+ test:
307
+ monorepo_mode: worktrees
308
+ jobs: 4
309
+ ```
310
+
311
+ Those worktrees isolate coverage, temporary files, and Bundler state. Their
312
+ changes are always discarded; test execution never materializes or commits
313
+ worktree artifacts.
314
+
284
315
  ### Release waves and dependency refresh
285
316
 
286
317
  Release waves are both a sequencing mechanism and a dependency propagation
@@ -533,9 +564,9 @@ stops before family changelog generation or coverage and reports the members
533
564
  that need a version bump. Run `kettle-family bump --execute patch` first, then
534
565
  rerun the release.
535
566
 
536
- State checks run concurrently by default, using up to four jobs or the number
537
- of available CPU cores, whichever is lower. Use `--jobs N` to set an explicit
538
- limit; results remain in family order.
567
+ State checks run concurrently by default, using up to half of the available CPU
568
+ cores and no more than the selected member count. Use `--jobs N` to set an
569
+ explicit limit; results remain in family order.
539
570
 
540
571
  Most commands accept `--json` to print a machine-readable report instead of the
541
572
  text report, and `--report PATH` to write that JSON report while still printing
@@ -732,9 +763,15 @@ kettle-family bump --execute --only unreleased minor
732
763
  ```
733
764
 
734
765
  Run `kettle-jem` templating across the selected family members. Planning shows
735
- the commands that would run; `--execute` runs them. Executed templating defaults
736
- to a parallel job count based on CPU cores, capped for readability, and can be
737
- overridden with `--jobs`.
766
+ the commands that would run; `--execute` runs them. Executed templating uses a
767
+ rolling outer wave of up to half the available CPU cores, bounded by ready
768
+ members; `--jobs` overrides that width.
769
+
770
+ For a Kettle Jem member command, Family exports the active outer width as
771
+ `KETTLE_FAMILY_WAVE_JOBS`. Kettle Jem retains the member's base process and
772
+ uses an equal share of CPUs left by that wave for its recipe and file worker
773
+ pools, capped at half the available CPUs. Explicit `KETTLE_JEM_*_WORKERS`
774
+ settings take precedence over this derived budget.
738
775
 
739
776
  ```console
740
777
  kettle-family template
@@ -1084,7 +1121,7 @@ Thanks for RTFM. ☺️
1084
1121
  [📌gitmoji]: https://gitmoji.dev
1085
1122
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1086
1123
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1087
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.247-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1124
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-6.494-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1088
1125
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1089
1126
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1090
1127
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -910,7 +910,7 @@ module Kettle
910
910
  io: stdout,
911
911
  label: "release state",
912
912
  total: members.length,
913
- jobs: [options[:jobs].to_i, 1].max,
913
+ jobs: Concurrency.wave_jobs(requested: options[:jobs], item_count: members.length),
914
914
  members: members,
915
915
  heading: "release state #{members.length} member#{"s" unless members.length == 1}:"
916
916
  )
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Kettle
6
+ module Family
7
+ # Allocates CPU capacity between concurrent family members and optional
8
+ # member-internal workers. A member consumes one wave slot before any
9
+ # command-specific workers are considered.
10
+ module Concurrency
11
+ module_function
12
+
13
+ def default_wave_jobs(cpu_count: Etc.nprocessors)
14
+ [cpu_count / 2, 1].max
15
+ end
16
+
17
+ def wave_jobs(requested:, item_count:, cpu_count: Etc.nprocessors)
18
+ return 0 if item_count.zero?
19
+
20
+ count = requested.nil? ? default_wave_jobs(cpu_count: cpu_count) : requested.to_i
21
+ count.clamp(1, item_count)
22
+ end
23
+
24
+ def internal_worker_limit(wave_jobs:, cpu_count: Etc.nprocessors)
25
+ raise ArgumentError, "wave jobs must be positive" unless wave_jobs.to_i.positive?
26
+
27
+ half_cores = default_wave_jobs(cpu_count: cpu_count)
28
+ available_per_member = (cpu_count - wave_jobs.to_i) / wave_jobs.to_i
29
+ available_per_member.clamp(1, half_cores)
30
+ end
31
+
32
+ # Test runners count their primary worker as part of the requested
33
+ # process pool. Reserve one process for each wave member, then divide
34
+ # the remaining capacity across them. No member receives more than half
35
+ # of the detected CPUs, including that primary process.
36
+ def test_process_ceiling(wave_jobs:, cpu_count: Etc.nprocessors)
37
+ [default_wave_jobs(cpu_count: cpu_count), 1 + internal_worker_limit(wave_jobs: wave_jobs, cpu_count: cpu_count)].min
38
+ end
39
+ end
40
+ end
41
+ end
@@ -208,6 +208,24 @@ module Kettle
208
208
  fetch_path("template", "jobs")
209
209
  end
210
210
 
211
+ # A monorepo commonly has one root suite that covers every subgem. The
212
+ # configured owner is optional; an --only invocation never expands its
213
+ # own selection solely to reach that owner.
214
+ def test_aggregate_member
215
+ fetch_path("test", "aggregate_member")
216
+ end
217
+
218
+ def test_monorepo_mode
219
+ mode = (fetch_path("test", "monorepo_mode") || "aggregate").to_s
220
+ return mode if %w[aggregate worktrees].include?(mode)
221
+
222
+ raise Error, "test.monorepo_mode must be aggregate or worktrees"
223
+ end
224
+
225
+ def test_jobs
226
+ fetch_path("test", "jobs")
227
+ end
228
+
211
229
  def readme_corporate_sponsors
212
230
  raw = fetch_path("readme", "corporate_sponsors") ||
213
231
  fetch_path("sponsorships", "corporate") ||
@@ -6,6 +6,8 @@ require "json"
6
6
  require "open3"
7
7
  require "time"
8
8
 
9
+ require_relative "concurrency"
10
+
9
11
  module Kettle
10
12
  module Family
11
13
  class LocalInstall
@@ -113,8 +115,7 @@ module Kettle
113
115
  end
114
116
 
115
117
  def install_jobs(candidate_members)
116
- count = jobs ? jobs.to_i : [Etc.nprocessors, 4].min
117
- count.clamp(1, candidate_members.length)
118
+ Concurrency.wave_jobs(requested: jobs, item_count: candidate_members.length)
118
119
  end
119
120
 
120
121
  def member_from_path(path)
@@ -9,6 +9,8 @@ require "rubygems"
9
9
  require "securerandom"
10
10
  require "yaml"
11
11
 
12
+ require_relative "concurrency"
13
+
12
14
  module Kettle
13
15
  module Family
14
16
  class ReleaseStateCheck
@@ -574,10 +576,7 @@ module Kettle
574
576
  end
575
577
 
576
578
  def state_jobs(items)
577
- return 1 if items.length < 2
578
-
579
- requested = jobs || [Etc.nprocessors, 4].min
580
- requested.to_i.clamp(1, items.length)
579
+ Concurrency.wave_jobs(requested: jobs, item_count: items.length)
581
580
  end
582
581
 
583
582
  def parallel_map(items, &block)
@@ -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.89"
8
+ VERSION = "1.2.90"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -11,6 +11,7 @@ require "shellwords"
11
11
  require "yaml"
12
12
  require "kettle/dev"
13
13
 
14
+ require_relative "concurrency"
14
15
  require_relative "workflow_progress"
15
16
 
16
17
  module Kettle
@@ -569,6 +570,7 @@ module Kettle
569
570
 
570
571
  def member_workflow_results(workflow_members)
571
572
  return template_member_workflow_results(workflow_members) if command == "template" && execute
573
+ return test_member_workflow_results(workflow_members) if command == "test" && execute
572
574
 
573
575
  runner = CommandRunner.new(execute: execute, accept: accept)
574
576
  results = []
@@ -614,6 +616,215 @@ module Kettle
614
616
  results
615
617
  end
616
618
 
619
+ # Sibling repositories have isolated working trees and can run their
620
+ # tests concurrently. A monorepo's default kettle-test command normally
621
+ # executes the shared root suite, so it is represented by one selected
622
+ # owner instead of repeating that suite per subgem.
623
+ def test_member_workflow_results(workflow_members)
624
+ test_members, skipped_members = test_members_for(workflow_members)
625
+ return [] if test_members.empty?
626
+
627
+ return monorepo_test_worktree_results(test_members) if monorepo_test_worktrees?(test_members)
628
+
629
+ wave_jobs = test_jobs(test_members)
630
+ queue = Queue.new
631
+ test_members.each_with_index { |member, index| queue << [index, member] }
632
+ ordered_results = Array.new(test_members.length)
633
+ mutex = Mutex.new
634
+ stop = false
635
+
636
+ Array.new(wave_jobs) do
637
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- sibling test suites run in isolated project roots.
638
+ runner = CommandRunner.new(execute: execute, accept: accept)
639
+ loop do
640
+ break if mutex.synchronize { stop }
641
+
642
+ index, member = queue.pop(true)
643
+ result = runner.call(
644
+ member: member,
645
+ phase: command,
646
+ command: workflow_command(member),
647
+ env: test_command_env(wave_jobs: wave_jobs)
648
+ )
649
+ mutex.synchronize do
650
+ ordered_results[index] = result
651
+ stop = true unless result.ok?
652
+ end
653
+ rescue ThreadError
654
+ break
655
+ rescue => error
656
+ mutex.synchronize do
657
+ ordered_results[index] = test_worker_error_result(member, error)
658
+ stop = true
659
+ end
660
+ break
661
+ end
662
+ end
663
+ end.each(&:join)
664
+
665
+ results = ordered_results.compact
666
+ return results unless results.all?(&:ok?)
667
+
668
+ results + skipped_members.map { |member| aggregate_test_skip_result(member, owner: test_members.first) }
669
+ end
670
+
671
+ def test_members_for(workflow_members)
672
+ return [workflow_members, []] unless aggregate_monorepo_test?(workflow_members)
673
+
674
+ owner_name = config.test_aggregate_member.to_s.strip
675
+ owner = workflow_members.find { |member| member.name == owner_name } || workflow_members.first
676
+ [[owner], workflow_members - [owner]]
677
+ end
678
+
679
+ def aggregate_monorepo_test?(workflow_members)
680
+ config.family_mode == "monorepo" &&
681
+ config.test_monorepo_mode == "aggregate" &&
682
+ workflow_members.length > 1
683
+ end
684
+
685
+ def monorepo_test_worktrees?(test_members)
686
+ config.family_mode == "monorepo" &&
687
+ config.test_monorepo_mode == "worktrees" &&
688
+ test_members.length > 1 &&
689
+ test_jobs(test_members) > 1
690
+ end
691
+
692
+ # Member-scoped monorepo suites cannot share the primary checkout: test
693
+ # coverage, temporary files, and Bundler state are all mutable. Unlike
694
+ # template worktrees, test worktrees intentionally discard every change.
695
+ def monorepo_test_worktree_results(test_members)
696
+ entries, setup_results = monorepo_test_worktree_entries(test_members)
697
+ return setup_results unless setup_results.all?(&:ok?)
698
+
699
+ run_monorepo_test_worktree_entries(entries).flatten
700
+ ensure
701
+ cleanup_monorepo_test_worktrees(entries || [])
702
+ end
703
+
704
+ def monorepo_test_worktree_entries(test_members)
705
+ runner = command_runner
706
+ control_member = monorepo_template_control_member
707
+ entries = []
708
+ results = []
709
+ test_members.each do |member|
710
+ worktree_root = monorepo_test_worktree_path(member)
711
+ result = runner.call(
712
+ member: control_member,
713
+ phase: "test_member_worktree_add",
714
+ command: ["git", "worktree", "add", "--detach", worktree_root, "HEAD"]
715
+ )
716
+ result.member_name = member.name
717
+ results << result
718
+ break unless result.ok?
719
+
720
+ entries << {
721
+ member: relocate_member_to_worktree(member, worktree_root, source_root: config.root),
722
+ original_member: member,
723
+ worktree_root: worktree_root
724
+ }
725
+ end
726
+ [entries, results]
727
+ end
728
+
729
+ def monorepo_test_worktree_path(member)
730
+ safe_member = member.name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")
731
+ File.join(config.root, "tmp", "kettle-family", "test-member-worktrees", safe_member, "#{Process.pid}-#{Time.now.to_i}")
732
+ end
733
+
734
+ def run_monorepo_test_worktree_entries(entries)
735
+ wave_jobs = test_jobs(entries)
736
+ queue = Queue.new
737
+ entries.each_with_index { |entry, index| queue << [index, entry] }
738
+ ordered_results = Array.new(entries.length)
739
+ mutex = Mutex.new
740
+ stop = false
741
+
742
+ Array.new(wave_jobs) do
743
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- detached worktrees isolate concurrent monorepo test suites.
744
+ loop do
745
+ break if mutex.synchronize { stop }
746
+
747
+ index, entry = queue.pop(true)
748
+ member = entry.fetch(:member)
749
+ result = CommandRunner.new(execute: execute, accept: accept).call(
750
+ member: member,
751
+ phase: command,
752
+ command: workflow_command(member),
753
+ env: test_command_env(wave_jobs: wave_jobs)
754
+ )
755
+ result.member_name = entry.fetch(:original_member).name
756
+ mutex.synchronize do
757
+ ordered_results[index] = [result]
758
+ stop = true unless result.ok?
759
+ end
760
+ rescue ThreadError
761
+ break
762
+ rescue => error
763
+ mutex.synchronize do
764
+ ordered_results[index] = [test_worker_error_result(entry.fetch(:original_member), error)]
765
+ stop = true
766
+ end
767
+ break
768
+ end
769
+ end
770
+ end.each(&:join)
771
+ ordered_results.compact
772
+ end
773
+
774
+ def cleanup_monorepo_test_worktrees(entries)
775
+ runner = command_runner
776
+ control_member = monorepo_template_control_member
777
+ entries.each do |entry|
778
+ root = entry.fetch(:worktree_root)
779
+ next unless Dir.exist?(root)
780
+
781
+ runner.call(member: control_member, phase: "test_member_worktree_remove", command: ["git", "worktree", "remove", "--force", root])
782
+ end
783
+ end
784
+
785
+ def aggregate_test_skip_result(member, owner:)
786
+ CommandResult.new(
787
+ member_name: member.name,
788
+ phase: command,
789
+ command: workflow_command(member),
790
+ workdir: member.root,
791
+ status: 0,
792
+ success: true,
793
+ stdout: "",
794
+ stderr: "",
795
+ elapsed_seconds: 0.0,
796
+ skipped: true,
797
+ reason: "shared monorepo suite owned by #{owner.name}"
798
+ )
799
+ end
800
+
801
+ def test_worker_error_result(member, error)
802
+ CommandResult.new(
803
+ member_name: member.name,
804
+ phase: command,
805
+ command: ["internal", "test-worker"],
806
+ workdir: member.root,
807
+ status: 1,
808
+ success: false,
809
+ stdout: "",
810
+ stderr: "#{error.class}: #{error.message}\n",
811
+ elapsed_seconds: 0.0,
812
+ skipped: false,
813
+ reason: error.message
814
+ )
815
+ end
816
+
817
+ def test_jobs(workflow_members)
818
+ requested = jobs || config.test_jobs
819
+ Concurrency.wave_jobs(requested: requested, item_count: workflow_members.length)
820
+ end
821
+
822
+ def test_command_env(wave_jobs:)
823
+ env = command_env
824
+ env["TURBO_TESTS2_MAX_PROCESSES"] ||= Concurrency.test_process_ceiling(wave_jobs: wave_jobs).to_s
825
+ env
826
+ end
827
+
617
828
  def review_gha_sha_pins(workflow_members, runner:, memo:, env: command_env)
618
829
  return true if workflow_members.empty?
619
830
 
@@ -719,17 +930,20 @@ module Kettle
719
930
  end
720
931
 
721
932
  def template_results_for_wave(wave, progress:)
933
+ return monorepo_template_worktree_wave_results(wave, progress: progress) if monorepo_template_worktrees?(wave)
934
+
935
+ wave_jobs = template_jobs(wave)
722
936
  queue = Queue.new
723
937
  wave.each_with_index { |member, index| queue << [index, member] }
724
938
  ordered_results = Array.new(wave.length)
725
939
  mutex = Mutex.new
726
940
  stop = false
727
- Array.new([template_jobs(wave), wave.length].min) do
941
+ Array.new(wave_jobs) do
728
942
  Thread.new do # rubocop:disable ThreadSafety/NewThread -- family templating intentionally runs independent members concurrently.
729
943
  loop do
730
944
  break if mutex.synchronize { stop }
731
945
  index, member = queue.pop(true)
732
- member_results = template_results_for_member(member, progress: progress)
946
+ member_results = template_results_for_member(member, progress: progress, wave_jobs: wave_jobs)
733
947
  mutex.synchronize do
734
948
  ordered_results[index] = member_results
735
949
  stop = true unless member_results.all?(&:ok?)
@@ -751,10 +965,15 @@ module Kettle
751
965
  ready = remaining.select do |member|
752
966
  (member.dependencies & by_name.keys).all? { |name| completed.include?(name) }
753
967
  end
754
- # A Git checkout is a shared mutable transaction boundary. Nested
755
- # gems in one checkout must not run kettle-jem concurrently, even
756
- # when their dependency graph would otherwise place them together.
757
- wave = ready.group_by { |member| git_root_for(member) }.values.map(&:first)
968
+ # A shared checkout remains a transaction boundary unless each
969
+ # ready monorepo member will receive an isolated worktree. The
970
+ # latter keeps the normal dependency graph intact while allowing
971
+ # independent sibling gems to use the requested worker budget.
972
+ wave = if monorepo_template_worktrees_enabled?
973
+ ready
974
+ else
975
+ ready.group_by { |member| git_root_for(member) }.values.map(&:first)
976
+ end
758
977
  raise Error, "template dependency wave could not resolve: #{remaining.map(&:name).join(", ")}" if wave.empty?
759
978
 
760
979
  waves << wave
@@ -764,6 +983,218 @@ module Kettle
764
983
  end
765
984
  end
766
985
 
986
+ # A monorepo has one mutable primary checkout, but its ready sibling
987
+ # gems can template independently. Run them in detached worktrees and
988
+ # materialize only changes beneath each member root before the existing
989
+ # serialized commit step runs in the primary checkout.
990
+ def monorepo_template_worktree_wave_results(wave, progress:)
991
+ entries, setup_results = monorepo_template_worktree_entries(wave)
992
+ return setup_results unless setup_results.all?(&:ok?)
993
+
994
+ worker_results = run_monorepo_template_worktree_entries(entries, progress: progress)
995
+ results = []
996
+ worker_results.each_with_index do |member_results, index|
997
+ next unless member_results
998
+
999
+ results.concat(member_results)
1000
+ next unless member_results.all?(&:ok?)
1001
+
1002
+ entry = entries.fetch(index)
1003
+ materialized = materialize_monorepo_template_changes(entry)
1004
+ results << materialized
1005
+ next unless materialized.ok?
1006
+
1007
+ commit_results = []
1008
+ commit_template_changes(member: entry.fetch(:original_member), runner: command_runner, memo: commit_results)
1009
+ results.concat(commit_results)
1010
+ end
1011
+ results
1012
+ ensure
1013
+ cleanup_monorepo_template_worktrees(entries || [])
1014
+ end
1015
+
1016
+ def monorepo_template_worktrees?(wave)
1017
+ monorepo_template_worktrees_enabled? && wave.length > 1
1018
+ end
1019
+
1020
+ def monorepo_template_worktrees_enabled?
1021
+ command_text = config.template_command || DEFAULT_COMMANDS.fetch("template")
1022
+ execute && monorepo_template? && kettle_jem_template_command?(command_text) && members.length > 1 && template_jobs(members) > 1
1023
+ end
1024
+
1025
+ def monorepo_template_worktree_entries(wave)
1026
+ runner = command_runner
1027
+ control_member = monorepo_template_control_member
1028
+ entries = []
1029
+ results = []
1030
+ wave.each do |member|
1031
+ worktree_root = monorepo_template_worktree_path(member)
1032
+ result = runner.call(
1033
+ member: control_member,
1034
+ phase: "template_member_worktree_add",
1035
+ command: ["git", "worktree", "add", "--detach", worktree_root, "HEAD"]
1036
+ )
1037
+ result.member_name = member.name
1038
+ results << result
1039
+ break unless result.ok?
1040
+
1041
+ entries << {
1042
+ member: relocate_member_to_worktree(member, worktree_root, source_root: config.root),
1043
+ original_member: member,
1044
+ worktree_root: worktree_root
1045
+ }
1046
+ end
1047
+ [entries, results]
1048
+ end
1049
+
1050
+ def monorepo_template_worktree_path(member)
1051
+ safe_member = member.name.to_s.gsub(/[^A-Za-z0-9_.-]+/, "_")
1052
+ File.join(config.root, "tmp", "kettle-family", "template-member-worktrees", safe_member, "#{Process.pid}-#{Time.now.to_i}")
1053
+ end
1054
+
1055
+ def monorepo_template_control_member
1056
+ family_member.dup.tap { |member| member.root = config.root }
1057
+ end
1058
+
1059
+ def run_monorepo_template_worktree_entries(entries, progress:)
1060
+ wave_jobs = template_jobs(entries)
1061
+ queue = Queue.new
1062
+ entries.each_with_index { |entry, index| queue << [index, entry] }
1063
+ ordered_results = Array.new(entries.length)
1064
+ mutex = Mutex.new
1065
+ stop = false
1066
+ Array.new(wave_jobs) do
1067
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- detached worktrees isolate concurrent monorepo template members.
1068
+ loop do
1069
+ break if mutex.synchronize { stop }
1070
+
1071
+ index, entry = queue.pop(true)
1072
+ member_results = template_results_for_member(
1073
+ entry.fetch(:member),
1074
+ progress: progress,
1075
+ commit_changes: false,
1076
+ wave_jobs: wave_jobs
1077
+ )
1078
+ mutex.synchronize do
1079
+ ordered_results[index] = member_results
1080
+ stop = true unless member_results.all?(&:ok?)
1081
+ end
1082
+ rescue ThreadError
1083
+ break
1084
+ rescue => error
1085
+ mutex.synchronize do
1086
+ ordered_results[index] = [template_worktree_failure_result(entry.fetch(:original_member), error)]
1087
+ stop = true
1088
+ end
1089
+ break
1090
+ end
1091
+ end
1092
+ end.each(&:join)
1093
+ ordered_results
1094
+ end
1095
+
1096
+ def materialize_monorepo_template_changes(entry)
1097
+ original_member = entry.fetch(:original_member)
1098
+ worktree_root = entry.fetch(:worktree_root)
1099
+ relative_root = Pathname.new(original_member.root).relative_path_from(Pathname.new(config.root)).to_s
1100
+ paths = monorepo_template_worktree_changed_paths(worktree_root)
1101
+ return template_worktree_failure_result(original_member, paths) if paths.is_a?(String)
1102
+
1103
+ invalid_paths = paths.reject { |path| path == relative_root || path.start_with?("#{relative_root}/") }
1104
+ unless invalid_paths.empty?
1105
+ return template_worktree_failure_result(
1106
+ original_member,
1107
+ "isolated template worker modified shared path(s): #{invalid_paths.sort.join(", ")}; only #{relative_root}/ may be materialized"
1108
+ )
1109
+ end
1110
+
1111
+ tracked_paths = monorepo_template_worktree_tracked_paths(worktree_root)
1112
+ return template_worktree_failure_result(original_member, tracked_paths) if tracked_paths.is_a?(String)
1113
+
1114
+ runner = command_runner
1115
+ control_member = monorepo_template_control_member.dup
1116
+ control_member.name = original_member.name
1117
+ command = if tracked_paths.empty?
1118
+ ["true"]
1119
+ else
1120
+ source = Shellwords.escape(worktree_root)
1121
+ scope = Shellwords.escape(relative_root)
1122
+ ["sh", "-lc", "git -C #{source} diff --binary HEAD -- #{scope} | git apply --whitespace=nowarn"]
1123
+ end
1124
+ result = runner.call(member: control_member, phase: "template_worktree_materialize", command: command)
1125
+ return result unless result.ok?
1126
+
1127
+ copy_monorepo_template_untracked_paths(worktree_root, config.root, relative_root)
1128
+ result.stdout = "#{paths.length} path(s) materialized from isolated worktree"
1129
+ result
1130
+ rescue => error
1131
+ template_worktree_failure_result(original_member, error)
1132
+ end
1133
+
1134
+ def monorepo_template_worktree_changed_paths(worktree_root)
1135
+ tracked_paths = monorepo_template_worktree_tracked_paths(worktree_root)
1136
+ return tracked_paths if tracked_paths.is_a?(String)
1137
+
1138
+ untracked_paths = monorepo_template_worktree_untracked_paths(worktree_root)
1139
+ return untracked_paths if untracked_paths.is_a?(String)
1140
+
1141
+ (tracked_paths + untracked_paths).uniq
1142
+ end
1143
+
1144
+ def monorepo_template_worktree_tracked_paths(worktree_root)
1145
+ git_worktree_paths(worktree_root, %w[diff --name-only -z HEAD])
1146
+ end
1147
+
1148
+ def monorepo_template_worktree_untracked_paths(worktree_root)
1149
+ git_worktree_paths(worktree_root, %w[ls-files --others --exclude-standard -z])
1150
+ end
1151
+
1152
+ def git_worktree_paths(worktree_root, arguments)
1153
+ stdout, stderr, status = Open3.capture3("git", *arguments, chdir: worktree_root)
1154
+ return stdout.split("\0").reject(&:empty?) if status.success?
1155
+
1156
+ "git #{arguments.join(" ")} failed in #{worktree_root}: #{stderr.strip}"
1157
+ end
1158
+
1159
+ def copy_monorepo_template_untracked_paths(worktree_root, primary_root, relative_root)
1160
+ monorepo_template_worktree_untracked_paths(worktree_root).each do |path|
1161
+ next unless path == relative_root || path.start_with?("#{relative_root}/")
1162
+
1163
+ source = File.join(worktree_root, path)
1164
+ destination = File.join(primary_root, path)
1165
+ FileUtils.mkdir_p(File.dirname(destination))
1166
+ FileUtils.cp_r(source, destination, preserve: true)
1167
+ end
1168
+ end
1169
+
1170
+ def cleanup_monorepo_template_worktrees(entries)
1171
+ runner = command_runner
1172
+ control_member = monorepo_template_control_member
1173
+ entries.each do |entry|
1174
+ root = entry.fetch(:worktree_root)
1175
+ next unless Dir.exist?(root)
1176
+
1177
+ runner.call(member: control_member, phase: "template_member_worktree_remove", command: ["git", "worktree", "remove", "--force", root])
1178
+ end
1179
+ end
1180
+
1181
+ def template_worktree_failure_result(member, error)
1182
+ message = error.is_a?(Exception) ? "#{error.class}: #{error.message}" : error.to_s
1183
+ CommandResult.new(
1184
+ member_name: member.name,
1185
+ phase: "template_member_worktree",
1186
+ command: ["internal", "template-member-worktree"],
1187
+ workdir: member.root,
1188
+ status: 1,
1189
+ success: false,
1190
+ stdout: "",
1191
+ stderr: message,
1192
+ elapsed_seconds: 0.0,
1193
+ skipped: false,
1194
+ reason: "template member worktree failed"
1195
+ )
1196
+ end
1197
+
767
1198
  def template_bootstrap_dependency_results(workflow_members)
768
1199
  return [] if workflow_members.empty?
769
1200
 
@@ -856,17 +1287,23 @@ module Kettle
856
1287
  )
857
1288
  end
858
1289
 
859
- def template_results_for_member(member, progress: nil)
1290
+ def template_results_for_member(member, progress: nil, commit_changes: true, wave_jobs: 1)
860
1291
  progress&.start_member(member, total: template_phase_total(member), status: template_initial_status(member))
861
1292
  runner = CommandRunner.new(execute: execute, accept: accept)
862
1293
  [].tap do |memo|
863
1294
  if config.normalize_lockfiles?
864
- normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "prepare_lockfiles")
1295
+ normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "prepare_lockfiles", commit_changes: commit_changes)
865
1296
  emit_member_result_progress(member, memo.last, progress: progress)
866
1297
  return memo unless memo.last.ok?
867
1298
  end
868
1299
 
869
- prepared = prepare_template_dependencies(member: member, runner: runner, memo: memo)
1300
+ prepared = prepare_template_dependencies(
1301
+ member: member,
1302
+ runner: runner,
1303
+ memo: memo,
1304
+ commit_changes: commit_changes,
1305
+ wave_jobs: wave_jobs
1306
+ )
870
1307
  emit_member_result_progress(member, memo.last, progress: progress) if memo.last&.phase == "prepare_template_dependencies"
871
1308
  return memo if prepared == false
872
1309
 
@@ -874,15 +1311,15 @@ module Kettle
874
1311
  member: member,
875
1312
  phase: command,
876
1313
  command: workflow_command(member),
877
- env: template_command_env,
1314
+ env: template_command_env(wave_jobs: wave_jobs),
878
1315
  stdout_line_handler: template_event_line_handler(member, progress: progress)
879
1316
  )
880
1317
  emit_member_result_progress(member, memo.last, progress: progress)
881
1318
  return memo unless memo.last.ok?
882
1319
 
883
- normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "normalize_lockfiles")
1320
+ normalize_lockfiles(member: member, runner: runner, memo: memo, phase: "normalize_lockfiles", commit_changes: commit_changes)
884
1321
  emit_member_result_progress(member, memo.last, progress: progress)
885
- commit_template_changes(member: member, runner: runner, memo: memo)
1322
+ commit_template_changes(member: member, runner: runner, memo: memo) if commit_changes
886
1323
  emit_member_result_progress(member, memo.last, progress: progress) if memo.last&.phase == "commit_template"
887
1324
  ensure
888
1325
  template_result = memo.find { |result| result.phase == "template" } || memo.last
@@ -898,8 +1335,7 @@ module Kettle
898
1335
 
899
1336
  def template_jobs(workflow_members)
900
1337
  requested = jobs || config.template_jobs
901
- count = requested ? requested.to_i : [Etc.nprocessors, 4].min
902
- count.clamp(1, workflow_members.length)
1338
+ Concurrency.wave_jobs(requested: requested, item_count: workflow_members.length)
903
1339
  end
904
1340
 
905
1341
  def check_results(workflow_members)
@@ -1056,17 +1492,18 @@ module Kettle
1056
1492
  end
1057
1493
 
1058
1494
  def template_branch_worktree_entries_results(entries)
1495
+ wave_jobs = template_jobs(entries)
1059
1496
  queue = Queue.new
1060
1497
  entries.each_with_index { |entry, index| queue << [index, entry] }
1061
1498
  ordered_results = Array.new(entries.length)
1062
1499
  mutex = Mutex.new
1063
1500
  stop = false
1064
- Array.new(template_jobs(entries)) do
1501
+ Array.new(wave_jobs) do
1065
1502
  Thread.new do # rubocop:disable ThreadSafety/NewThread -- independent branch worktrees are safe to template concurrently.
1066
1503
  loop do
1067
1504
  break if mutex.synchronize { stop }
1068
1505
  index, entry = queue.pop(true)
1069
- branch_results = template_branch_worktree_entry_results(entry)
1506
+ branch_results = template_branch_worktree_entry_results(entry, wave_jobs: wave_jobs)
1070
1507
  mutex.synchronize do
1071
1508
  ordered_results[index] = branch_results
1072
1509
  stop = true unless branch_results.all?(&:ok?)
@@ -1079,7 +1516,7 @@ module Kettle
1079
1516
  ordered_results.compact.flatten
1080
1517
  end
1081
1518
 
1082
- def template_branch_worktree_entry_results(entry)
1519
+ def template_branch_worktree_entry_results(entry, wave_jobs: 1)
1083
1520
  member = entry.fetch(:member)
1084
1521
  branch = entry.fetch(:branch)
1085
1522
  runner = command_runner
@@ -1087,7 +1524,7 @@ module Kettle
1087
1524
  if git_upstream_for(member)
1088
1525
  results << runner.call(member: member, phase: "template_branch_sync", command: ["git", "rebase", "@{upstream}"])
1089
1526
  end
1090
- results.concat(template_results_for_member(member)) if results.all?(&:ok?)
1527
+ results.concat(template_results_for_member(member, wave_jobs: wave_jobs)) if results.all?(&:ok?)
1091
1528
  tag_branch_results(results, branch)
1092
1529
  emit_template_event_line(member, results.all?(&:ok?) ? "." : "F", "branch #{branch}")
1093
1530
  results
@@ -1452,7 +1889,7 @@ module Kettle
1452
1889
  waves.each_with_index do |wave, index|
1453
1890
  results << release_wave_result(wave, index: index, total: waves.length, jobs: 1) if show_wave_markers
1454
1891
  wave.each do |member|
1455
- results.concat(release_results_for_member(member, runner: runner))
1892
+ results.concat(release_results_for_member(member, runner: runner, wave_jobs: 1))
1456
1893
  break unless results.last.ok?
1457
1894
 
1458
1895
  remaining_members = ordered_members.drop(ordered_members.index(member) + 1)
@@ -1481,7 +1918,7 @@ module Kettle
1481
1918
  begin
1482
1919
  waves.each_with_index do |wave, index|
1483
1920
  results << release_wave_result(wave, index: index, total: waves.length)
1484
- wave_results = run_release_wave(wave)
1921
+ wave_results = run_release_wave(wave, wave_jobs: release_jobs(wave))
1485
1922
  results.concat(wave_results.flatten)
1486
1923
  break unless wave_results.all? { |member_results| member_results.all?(&:ok?) }
1487
1924
 
@@ -1498,11 +1935,10 @@ module Kettle
1498
1935
  results
1499
1936
  end
1500
1937
 
1501
- def run_release_wave(wave)
1938
+ def run_release_wave(wave, wave_jobs: release_jobs(wave))
1502
1939
  queue = Queue.new
1503
1940
  wave.each_with_index { |member, index| queue << [index, member] }
1504
1941
  ordered_results = Array.new(wave.length)
1505
- wave_jobs = release_jobs(wave)
1506
1942
  mutex = Mutex.new
1507
1943
  stop = false
1508
1944
  Array.new(wave_jobs) do
@@ -1512,7 +1948,7 @@ module Kettle
1512
1948
  break if mutex.synchronize { stop }
1513
1949
 
1514
1950
  index, member = queue.pop(true)
1515
- member_results = release_results_for_member(member, runner: runner)
1951
+ member_results = release_results_for_member(member, runner: runner, wave_jobs: wave_jobs)
1516
1952
  mutex.synchronize do
1517
1953
  ordered_results[index] = member_results
1518
1954
  stop = true unless member_results.all?(&:ok?)
@@ -1543,7 +1979,7 @@ module Kettle
1543
1979
  aggregate_members, independent_members = wave.partition { |member| aggregate_release_member?(member) }
1544
1980
 
1545
1981
  unless aggregate_members.empty?
1546
- worker_results = run_monorepo_release_wave(aggregate_members)
1982
+ worker_results = run_monorepo_release_wave(aggregate_members, wave_jobs: release_jobs(aggregate_members))
1547
1983
  results.concat(worker_results.flatten)
1548
1984
  break unless worker_results.all? { |member_results| member_results.all?(&:ok?) }
1549
1985
 
@@ -1553,7 +1989,7 @@ module Kettle
1553
1989
  end
1554
1990
 
1555
1991
  independent_members.each do |member|
1556
- results.concat(release_results_for_member(member, runner: release_command_runner))
1992
+ results.concat(release_results_for_member(member, runner: release_command_runner, wave_jobs: 1))
1557
1993
  break unless results.last.ok?
1558
1994
  end
1559
1995
  break unless results.last&.ok?
@@ -1571,13 +2007,13 @@ module Kettle
1571
2007
  results
1572
2008
  end
1573
2009
 
1574
- def run_monorepo_release_wave(members)
2010
+ def run_monorepo_release_wave(members, wave_jobs: release_jobs(members))
1575
2011
  entries, setup_results = monorepo_release_worktree_entries(members)
1576
2012
  unless setup_results.all?(&:ok?)
1577
2013
  return setup_results.zip
1578
2014
  end
1579
2015
 
1580
- worker_results = run_monorepo_release_worktree_entries(entries)
2016
+ worker_results = run_monorepo_release_worktree_entries(entries, wave_jobs: wave_jobs)
1581
2017
  materialization_results = materialize_monorepo_release_artifacts(entries, worker_results)
1582
2018
  worker_results.each_with_index.map do |member_results, index|
1583
2019
  artifact_result = materialization_results[index]
@@ -1616,19 +2052,19 @@ module Kettle
1616
2052
  family_member.dup.tap { |member| member.root = config.root }
1617
2053
  end
1618
2054
 
1619
- def run_monorepo_release_worktree_entries(entries)
2055
+ def run_monorepo_release_worktree_entries(entries, wave_jobs: release_jobs(entries))
1620
2056
  queue = Queue.new
1621
2057
  entries.each_with_index { |entry, index| queue << [index, entry] }
1622
2058
  ordered_results = Array.new(entries.length)
1623
2059
  mutex = Mutex.new
1624
2060
  stop = false
1625
- Array.new(release_jobs(entries)) do
2061
+ Array.new(wave_jobs) do
1626
2062
  Thread.new do # rubocop:disable ThreadSafety/NewThread -- each member has an isolated detached monorepo worktree.
1627
2063
  loop do
1628
2064
  break if mutex.synchronize { stop }
1629
2065
 
1630
2066
  index, entry = queue.pop(true)
1631
- member_results = release_results_for_monorepo_worktree(entry)
2067
+ member_results = release_results_for_monorepo_worktree(entry, wave_jobs: wave_jobs)
1632
2068
  mutex.synchronize do
1633
2069
  ordered_results[index] = member_results
1634
2070
  stop = true unless member_results.all?(&:ok?)
@@ -1647,7 +2083,7 @@ module Kettle
1647
2083
  ordered_results.compact
1648
2084
  end
1649
2085
 
1650
- def release_results_for_monorepo_worktree(entry)
2086
+ def release_results_for_monorepo_worktree(entry, wave_jobs: 1)
1651
2087
  member = entry.fetch(:member)
1652
2088
  original_member = entry.fetch(:original_member)
1653
2089
  progress = @release_progress
@@ -1668,7 +2104,7 @@ module Kettle
1668
2104
  member: member,
1669
2105
  phase: release_phase,
1670
2106
  command: release_command_for(member),
1671
- env: release_env_for_member(member, family_root: entry.fetch(:worktree_root)).merge("KETTLE_RELEASE_FAMILY_MEMBER_PUBLISH" => "true"),
2107
+ env: release_env_for_member(member, family_root: entry.fetch(:worktree_root), wave_jobs: wave_jobs).merge("KETTLE_RELEASE_FAMILY_MEMBER_PUBLISH" => "true"),
1672
2108
  interactive: release_command_interactive?,
1673
2109
  stdout_line_handler: release_event_line_handler(original_member, progress: progress, state: release_event_state),
1674
2110
  log_path: release_command_log_path(original_member, release_phase),
@@ -1805,7 +2241,7 @@ module Kettle
1805
2241
  )
1806
2242
  end
1807
2243
 
1808
- def release_results_for_member(member, runner:)
2244
+ def release_results_for_member(member, runner:, wave_jobs: 1)
1809
2245
  progress = @release_progress
1810
2246
  progress&.start_member(member, total: release_phase_total(member), status: "check")
1811
2247
  [].tap do |memo|
@@ -1840,7 +2276,7 @@ module Kettle
1840
2276
  member: member,
1841
2277
  phase: release_phase,
1842
2278
  command: release_command_for(member),
1843
- env: release_env_for_member(member),
2279
+ env: release_env_for_member(member, wave_jobs: wave_jobs),
1844
2280
  interactive: release_command_interactive?,
1845
2281
  stdout_line_handler: release_event_line_handler(member, progress: progress, state: release_event_state),
1846
2282
  log_path: release_command_log_path(member, release_phase),
@@ -1917,8 +2353,7 @@ module Kettle
1917
2353
  return 1 if truffleruby?
1918
2354
 
1919
2355
  requested = jobs || config.release_jobs
1920
- count = requested ? requested.to_i : [Etc.nprocessors, 4].min
1921
- count.clamp(1, release_members.length)
2356
+ Concurrency.wave_jobs(requested: requested, item_count: release_members.length)
1922
2357
  end
1923
2358
 
1924
2359
  def truffleruby?
@@ -2963,7 +3398,7 @@ module Kettle
2963
3398
  # A monorepo member runs kettle-release from its own checkout, while its
2964
3399
  # changelog may live at the family root. Pass the shared paths to the
2965
3400
  # member release phase as well as to the separate family phase.
2966
- def release_env_for_member(member, family_root: config.root)
3401
+ def release_env_for_member(member, family_root: config.root, wave_jobs: 1)
2967
3402
  env = release_env
2968
3403
  env.merge!(release_wave_local_path_env_for(member, family_root: family_root))
2969
3404
  env.merge!(release_local_path_policy_env(family_root: family_root))
@@ -2997,6 +3432,10 @@ module Kettle
2997
3432
  if aggregate_validation_member?(member)
2998
3433
  env["KETTLE_RELEASE_FAMILY_CI_MODE"] = aggregate_validation_member_name?(member) ? "validation" : "member"
2999
3434
  end
3435
+ # kettle-release invokes kettle-test as part of its member lifecycle.
3436
+ # Preserve an operator-supplied ceiling, otherwise budget the complete
3437
+ # TurboTests2 process pool for this active Family wave.
3438
+ env["TURBO_TESTS2_MAX_PROCESSES"] ||= Concurrency.test_process_ceiling(wave_jobs: wave_jobs).to_s
3000
3439
  return env unless config.shared_changelog?
3001
3440
  if config.member_local_changelog?(member)
3002
3441
  return env.merge(
@@ -3592,7 +4031,6 @@ module Kettle
3592
4031
  env["BUNDLE_DISABLE_CHECKSUM_VALIDATION"] = "true"
3593
4032
  env["KETTLE_JEM_TEMPLATE_PROFILE"] = config.template_profile if config.template_profile
3594
4033
  env["KJ_REPOSITORY_TOPOLOGY"] = config.template_repository_topology if config.template_repository_topology
3595
- env["KETTLE_JEM_THREAD_WORKERS"] ||= template_thread_worker_budget.to_s
3596
4034
  if monorepo_template?
3597
4035
  template_git_lock_path = template_git_commit_lock_path
3598
4036
  env["KETTLE_JEM_GIT_LOCK"] = template_git_lock_path
@@ -4348,7 +4786,7 @@ module Kettle
4348
4786
  "#{outcomes.fetch(:checksum_hits)} checksum hits#{protected_label}, #{outcomes.fetch(:unchanged)} unchanged, #{changed_label}"
4349
4787
  end
4350
4788
 
4351
- def normalize_lockfiles(member:, runner:, memo:, phase:)
4789
+ def normalize_lockfiles(member:, runner:, memo:, phase:, commit_changes: true)
4352
4790
  return unless config.normalize_lockfiles?
4353
4791
 
4354
4792
  env = template_lockfile_phase?(phase) ? template_prepare_env : workflow_env
@@ -4384,7 +4822,7 @@ module Kettle
4384
4822
  )
4385
4823
  end
4386
4824
  if template_lockfile_phase?(phase) && recoverable_bundle_failure?(result)
4387
- recover_template_lockfiles(member: member, runner: runner, memo: memo, phase: "#{phase}_recovery")
4825
+ recover_template_lockfiles(member: member, runner: runner, memo: memo, phase: "#{phase}_recovery", commit_changes: commit_changes)
4388
4826
  return unless memo.last&.ok?
4389
4827
 
4390
4828
  result = runner.call(
@@ -4397,7 +4835,7 @@ module Kettle
4397
4835
  memo << result
4398
4836
  end
4399
4837
 
4400
- def recover_template_lockfiles(member:, runner:, memo:, phase:)
4838
+ def recover_template_lockfiles(member:, runner:, memo:, phase:, commit_changes: true)
4401
4839
  result = runner.call(
4402
4840
  member: member,
4403
4841
  phase: phase,
@@ -4407,13 +4845,9 @@ module Kettle
4407
4845
  memo << result
4408
4846
  return unless memo.last&.ok?
4409
4847
 
4410
- commit_normalized_lockfiles(
4411
- branch_members: [member],
4412
- runner: runner,
4413
- memo: memo,
4414
- reason: "reset",
4415
- force: true
4416
- )
4848
+ return unless commit_changes
4849
+
4850
+ commit_normalized_lockfiles(branch_members: [member], runner: runner, memo: memo, reason: "reset", force: true)
4417
4851
  end
4418
4852
 
4419
4853
  def recoverable_bundle_failure?(result)
@@ -4555,7 +4989,7 @@ module Kettle
4555
4989
  output.to_s.scan(/from the lockfile CHECKSUMS at Gemfile\.lock:(\d+):\d+/).flatten.map(&:to_i).uniq
4556
4990
  end
4557
4991
 
4558
- def prepare_template_dependencies(member:, runner:, memo:)
4992
+ def prepare_template_dependencies(member:, runner:, memo:, commit_changes: true, wave_jobs: 1)
4559
4993
  command_text = config.template_command || default_template_command(member)
4560
4994
  return true unless kettle_jem_template_command?(command_text)
4561
4995
 
@@ -4563,14 +4997,15 @@ module Kettle
4563
4997
  member: member,
4564
4998
  phase: "prepare_template_dependencies",
4565
4999
  command: template_prepare_command(member),
4566
- env: template_prepare_env
5000
+ env: template_prepare_env(wave_jobs: wave_jobs)
4567
5001
  )
4568
5002
  if recoverable_bundle_failure?(result)
4569
5003
  recover_template_lockfiles(
4570
5004
  member: member,
4571
5005
  runner: runner,
4572
5006
  memo: memo,
4573
- phase: "prepare_template_dependencies_recovery"
5007
+ phase: "prepare_template_dependencies_recovery",
5008
+ commit_changes: commit_changes
4574
5009
  )
4575
5010
  if no_release_lockfiles_failure?(memo.last)
4576
5011
  memo.pop
@@ -4583,7 +5018,7 @@ module Kettle
4583
5018
  member: member,
4584
5019
  phase: "prepare_template_dependencies",
4585
5020
  command: template_prepare_command(member),
4586
- env: template_prepare_env
5021
+ env: template_prepare_env(wave_jobs: wave_jobs)
4587
5022
  )
4588
5023
  end
4589
5024
  memo << result
@@ -4635,15 +5070,17 @@ module Kettle
4635
5070
  end
4636
5071
  end
4637
5072
 
4638
- def template_prepare_env
5073
+ def template_prepare_env(wave_jobs: 1)
4639
5074
  # Templating is development work. It must resolve the configured
4640
5075
  # family graph exactly as template application does; release-only
4641
5076
  # lockfile cleanup owns disabling local path sources.
4642
- execution_profile(template_execution_profile, workflow_env)
5077
+ execution_profile(template_execution_profile, workflow_env).merge(
5078
+ "KETTLE_FAMILY_WAVE_JOBS" => wave_jobs.to_s
5079
+ )
4643
5080
  end
4644
5081
 
4645
- def template_command_env
4646
- (command == "template") ? template_prepare_env : command_env
5082
+ def template_command_env(wave_jobs: 1)
5083
+ (command == "template") ? template_prepare_env(wave_jobs: wave_jobs) : command_env
4647
5084
  end
4648
5085
 
4649
5086
  def template_execution_profile
@@ -4680,11 +5117,6 @@ module Kettle
4680
5117
  env_overrides.key?(name) || ENV.key?(name)
4681
5118
  end
4682
5119
 
4683
- def template_thread_worker_budget
4684
- member_jobs = template_jobs(members)
4685
- [1, (Etc.nprocessors / member_jobs) - 1].max
4686
- end
4687
-
4688
5120
  def normalize_release_lockfiles(member:, runner:, memo:)
4689
5121
  result = runner.call(
4690
5122
  member: member,
data/lib/kettle/family.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "family/secrets"
13
13
  require_relative "family/secrets_broker"
14
14
  require_relative "family/branch_target_config"
15
15
  require_relative "family/release_state_event_tape"
16
+ require_relative "family/concurrency"
16
17
  require_relative "family/release_state_check"
17
18
  require_relative "family/release_reconciler"
18
19
  require_relative "family/unreleased_gem_cleanup"
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.89
4
+ version: 1.2.90
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -410,6 +410,7 @@ files:
410
410
  - lib/kettle/family/cli.rb
411
411
  - lib/kettle/family/command_result.rb
412
412
  - lib/kettle/family/command_runner.rb
413
+ - lib/kettle/family/concurrency.rb
413
414
  - lib/kettle/family/config.rb
414
415
  - lib/kettle/family/debugger_bootstrap.rb
415
416
  - lib/kettle/family/dependency_floor.rb
@@ -442,10 +443,10 @@ licenses:
442
443
  - AGPL-3.0-only
443
444
  metadata:
444
445
  homepage_uri: https://kettle-family.galtzo.com
445
- source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.89
446
- changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.89/CHANGELOG.md
446
+ source_code_uri: https://github.com/kettle-dev/kettle-family/tree/v1.2.90
447
+ changelog_uri: https://github.com/kettle-dev/kettle-family/blob/v1.2.90/CHANGELOG.md
447
448
  bug_tracker_uri: https://github.com/kettle-dev/kettle-family/issues
448
- documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.89
449
+ documentation_uri: https://www.rubydoc.info/gems/kettle-family/1.2.90
449
450
  funding_uri: https://github.com/sponsors/pboling
450
451
  wiki_uri: https://github.com/kettle-dev/kettle-family/wiki
451
452
  news_uri: https://www.railsbling.com/tags/kettle-family
metadata.gz.sig CHANGED
Binary file