kettle-family 1.3.6 → 1.3.9

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: 8d912f3b7a33740ace19f7f0568177189b662819bec18bf94312de460f640fd9
4
- data.tar.gz: d70a03c67ed0a96ce645d00ff769a920b500b3d1d3b231324a2d8170ea683cf1
3
+ metadata.gz: f74d2fca80b37d1577e7212df092067958426dc780d1e07594b545d057a7321e
4
+ data.tar.gz: 23f68c9dd77d68828708ecfea9d64c87af8b12182689e9c9c15e0dfddc6648d5
5
5
  SHA512:
6
- metadata.gz: ca24fe1d3dff219117a561ecd8ff6d45edd6c1a4d4fb6db9172c874f8964676162747de8d3d45fa0caf1aa7707ec2733141eda5a993bbbcbc8d0fd81d5f8543c
7
- data.tar.gz: b3f48464d35121fdee21d6d23158e81630ac373676a6581d876f2887e735cc60d5cb70c4a87359090b124a56707b544d13dd1b30f5745ecf93ef81775eb03bed
6
+ metadata.gz: 327d0135be9e3ee4fb1abea1794d3acf2e714f791b3e83063bb6290a5466ada57b0d49b969f85cbde18050499c34bb57a2334cd7b2a98857f411fe4b015147d9
7
+ data.tar.gz: 21892de4191d48f57e7b9183d6a45bbb7d6ef5b3ba1b1a3a4922ba42b39da09ddb0f3d8ba30d3e6c86730a86c876023e405b1d93617896f369916245b6b45718
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,65 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.9] - 2026-10-10
34
+
35
+ - TAG: [v1.3.9][1.3.9t]
36
+ - COVERAGE: 93.43% -- 7068/7565 lines in 43 files
37
+ - BRANCH COVERAGE: 77.95% -- 2832/3633 branches in 43 files
38
+ - 35.42% documented
39
+
40
+ ### Added
41
+
42
+ - New `clean-blockers` command uninstalls locally installed gems, from any family, whose unreleased versions are pinned by members’ lockfiles. Unlike `clean-unreleased`, which can only remove a family sibling newer than its latest release, it removes cross-family offenders: a kettle-dev member blocked by unreleased StructuredMerge gems, or vice versa.
43
+
44
+ - Add clean-installed: the deterministic inverse of install. Uninstalls each selected member's current source version (plus configured install local_dependencies, which install also covers) when that version is unreleased, including cached .gem files. The other two cleanups cannot see this pollution class: clean-unreleased derives candidates from release state and can miss versions it judges released, and clean-blockers only sees lockfile pins, so an installed-but-unpinned gem (observed: commonmarker-merge 7.1.10) survives and then breaks gem activation with a dangling exact pin once the rest of its series is removed. Release state comes from the same ReleaseStateCheck clean-unreleased uses; members at a released source version are left alone. Also extracts MemberLoader from LocalInstall so install and clean-installed construct members identically.
45
+
46
+ - Add a release-mode pre-clean to bup, bupb, and update-bundler: before a registry-only re-resolution, installed gems whose versions no registry publishes are removed, including cross-family gems named in member lockfiles. Previously the release-mode update only detected such pins after the fact (PublishedVersionCheck) and its advice to re-run the update was circular, because bundler resolves against the local gem dir and >= floors always re-pin the installed unreleased version. Removal up front turns that opaque post-update failure into a resolved-before-it-mattered cleanup. Fails open when the registry is unreachable: a network blip never removes gems. Also adds the standalone clean-installed command, the deterministic inverse of install, which uninstalls each member's unreleased source version (plus install local_dependencies) including cached .gem files.
47
+
48
+ ### Changed
49
+
50
+ - [kc] kettle-jem/prepare: updated 2 project files:
51
+ - dependencies (2)
52
+
53
+ - [kc] kettle-jem/template: updated 3 project files:
54
+ - code and tests (1)
55
+ - documentation (1)
56
+ - other (1)
57
+
58
+ ### Fixed
59
+
60
+ - Add a structured CommandResult#diagnostics channel carrying complete, machine-readable findings. stdout is a human summary truncated to its last 20 lines, so a JSON consumer parsing it to decide what to act on silently acted on an incomplete set: a real cross-family release reported 28 unpublished lockfile pins and the JSON carried only 20, losing 8 gems. PublishedVersionCheck now emits each pin as gem, version, remote, member, and lockfile data alongside its message.
61
+
62
+ - clean-blockers (and clean-unreleased, sharing GemUninstallSupport): two uninstall bugs fixed. The batched gem uninstall now retries once with --ignore-dependencies when RubyGems aborts on a dependent outside the batch (Gem::DependencyRemovalException), re-batching only what survived the abort; the dependency refusal previously left a partial cleanup reported as failure. And the cleanup now removes the cached .gem files as well as the installed specifications -- gem uninstall leaves cache/<name>-<version>.gem behind, and the next bundle install was reinstalling the same unreleased versions from that cache without touching the network, so the cleanup did not hold.
63
+
64
+ - release: stop handing member kettle-release children the family-root dev bundle via BUNDLE_GEMFILE in release mode. That bundle resolves the family's own members, which are unreleased while any member is being released, so the child's kettle-release died booting (observed: "Could not find kettle-family-1.3.9 ... in locally installed gems" for every member). In release mode the child now resolves kettle-release from its own released dependency; local release tooling (KETTLE_DEV_DEV set) still pins the local kettle-dev bundle.
65
+
66
+ ## [1.3.7] - 2026-10-09
67
+
68
+ - TAG: [v1.3.7][1.3.7t]
69
+ - COVERAGE: 93.30% -- 6778/7265 lines in 38 files
70
+ - BRANCH COVERAGE: 77.95% -- 2757/3537 branches in 38 files
71
+ - 34.47% documented
72
+
73
+ ### Changed
74
+
75
+ - Reject --start-at for template and test instead of silently dropping members. Those commands dispatch in dependency waves and a concurrent queue, so the set of members that still need work is not a suffix of the configured order and no positional value can select it. The flag now exits 1 with a message pointing at the equivalent --only selection. release is deliberately left accepting --start-at: it uses MEMBER@BRANCH to select a branch lane via release_target_branches, and its own resume hints already use --only rather than --start-at. Sequentially dispatched commands (lint, bup, check, docs, install, sync, push, pull, bump) keep positional --start-at, which is correct because they abort at the first failure so pending genuinely is a suffix.
76
+
77
+ - [kc] kettle-jem/prepare: updated 5 project files:
78
+ - dependencies (5)
79
+
80
+ - [kc] kettle-jem/template: updated 2 project files:
81
+ - code and tests (1)
82
+ - other (1)
83
+
84
+ ### Fixed
85
+
86
+ - Fix clean-unreleased missing locally installed unreleased versions. Under `bundle exec`, every RubyGems enumeration API (find_all_by_name, stubs_for, Specification.all) reports only the versions the active bundle resolves, not everything installed in the gem home. An unreleased version that was built and installed but is not pinned by the lockfile was therefore invisible, so cleanup reported "no unreleased installed versions found" with outcome success while leaving the offender installed -- blind in exactly the state it exists to correct, since re-resolving the lockfile to released versions is what hides the offender. Installed versions are now read by scanning the specification directories under each Gem.path root, which sees every installed version regardless of bundle activation and still excludes PATH/git source checkouts. Verified against the kettle-dev family: the fix now surfaces 10 unreleased members (kettle-rb 0.1.16, kettle-test 2.0.24, token-resolver 2.0.13, and others) that the previous implementation reported as none, while correctly leaving the two released members (kettle-dev 3.1.8, kettle-family 1.3.6) alone.
87
+
88
+ - Fix clean-unreleased --execute reporting success when gem uninstall removes nothing. RubyGems exits 0 and prints "Gem 'name' is not installed" when the target is absent from GEM_HOME, so an exit-status check could not distinguish a completed cleanup from one that silently did nothing -- the same silent-success failure mode as the enumeration bug. The batch outcome is now verified by re-enumerating installed versions afterwards, and any survivor turns the result into a failure that names it. Investigated and deliberately not implemented: `gem uninstall -i <root>` is not a workaround for a gem home other than GEM_HOME, because it fails with the same "not installed in GEM_HOME" error even when the path equals GEM_HOME. That case already fails loudly with a non-zero exit rather than being reported as success.
89
+
90
+ - Fix the resume hint for template and test silently dropping members that never ran. Those commands do not dispatch positionally: template groups members into dependency waves derived from gemspec dependencies, test uses a concurrent work queue, and in both a failure only sets a stop flag that prevents threads from popping further work -- members already in flight finish while later waves never run. The pending set is therefore interleaved through the configured order rather than being a suffix of it, so the emitted positional `--start-at <failed member>` dropped every pending member ordered ahead of the failure. Measured on the kettle-dev family: nomono failed in template wave 1 and left kettle-dev, kettle-changelog, kettle-family and kettle-soup-cover pending at ordered positions 6, 8, 9 and 11 while kettle-drift at 7 and kettle-wash at 10 had succeeded, so `--start-at nomono` resumed only nomono and token-resolver and reported outcome success having never templated the other four. Resume hints for these commands are now set-based, mirroring release: one `--only` hint per failure plus a combined `--only` hint naming every member that never ran. Sequentially dispatched commands keep positional `--start-at`, which remains correct because they abort at the first failure. A failed family-root phase still restarts the whole command, since it aborts before member dispatch. Note that template and test ignore the configured `release.waves`, which only affect release ordering.
91
+
33
92
  ## [1.3.6] - 2026-10-07
34
93
 
35
94
  - TAG: [v1.3.6][1.3.6t]
@@ -2905,7 +2964,11 @@ Please file a bug if you notice a violation of semantic versioning.
2905
2964
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2906
2965
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2907
2966
 
2908
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...HEAD
2967
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.9...HEAD
2968
+ [1.3.9]: https://github.com/kettle-dev/kettle-family/compare/v1.3.7...v1.3.9
2969
+ [1.3.9t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.9
2970
+ [1.3.7]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...v1.3.7
2971
+ [1.3.7t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.7
2909
2972
  [1.3.6]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...v1.3.6
2910
2973
  [1.3.6t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.6
2911
2974
  [1.3.5]: https://github.com/kettle-dev/kettle-family/compare/v1.3.4...v1.3.5
data/README.md CHANGED
@@ -1192,7 +1192,7 @@ Thanks for RTFM. ☺️
1192
1192
  [📌gitmoji]: https://gitmoji.dev
1193
1193
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
1194
1194
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
1195
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.231-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1195
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.565-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1196
1196
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1197
1197
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1198
1198
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kettle/dev/lockfile_reset"
4
+
5
+ module Kettle
6
+ module Family
7
+ # Removes locally installed gem versions that block a command, including gems
8
+ # belonging to OTHER families.
9
+ #
10
+ # Why this exists alongside +UnreleasedGemCleanup+ (`clean-unreleased`): that
11
+ # command derives candidates from family members' release state, so it can
12
+ # only ever remove a member of its own family whose installed version exceeds
13
+ # the latest release. The gems that actually block a release are usually
14
+ # cross-family, and each family is blocked by the other's legitimately
15
+ # installed development versions. Measured live:
16
+ #
17
+ # kettle-dev 28 pins ast-crispr 7.1.10, ast-merge, tree_haver, ...
18
+ # kettle-drift 22 pins (all structuredmerge gems)
19
+ # kettle-family 28 pins (all structuredmerge gems)
20
+ # kettle-changelog 2 pins nomono 1.1.7, token-resolver 2.0.13
21
+ # kettle-jem 3 pins kettle-test 2.0.24, nomono, token-resolver
22
+ # smorg-rb 3 pins (all kettle-dev gems)
23
+ #
24
+ # Neither family's `clean-unreleased` can see the other's offenders, which is
25
+ # what forced the kettle-dev 3.1.9 and kettle-family 1.3.7 releases to be
26
+ # finished manually after `gem push`: the tag, checksum and GitHub Release
27
+ # steps all run through a release-mode dependency update, and that cannot
28
+ # resolve while cross-family unreleased pins are present.
29
+ #
30
+ # The blocker set comes from calling PublishedVersionCheck directly on each
31
+ # selected member and reading its structured diagnostics, NOT from running the
32
+ # blocked command and parsing its report. A dry-run release cannot surface
33
+ # them: workflow.rb:2522-2526 replaces the member result with a synthetic
34
+ # "release readiness requires lockfile normalization" entry before
35
+ # append_release_internal_checks runs, so published_version_check never
36
+ # appears and every diagnostics array is empty. The findings are computed
37
+ # inside release_lockfile_readiness_would_fail? (workflow.rb:5785-5794) and
38
+ # then discarded in favour of a boolean. Running with --execute to obtain them
39
+ # would run the release, which is what we are trying to unblock.
40
+ #
41
+ # PublishedVersionCheck is read-only, so this is safe to run without
42
+ # --execute; only the uninstall itself is gated.
43
+ class BlockerCleanup
44
+ PHASE = "clean_blockers"
45
+ BLOCKER_KIND = "unpublished_lockfile_pin"
46
+ NO_BLOCKERS_MESSAGE = "no unpublished lockfile pins found"
47
+ # A member in local-gem mode resolves its family siblings through PATH
48
+ # remotes, so its lockfile has no GEM-section specs to check. Reporting
49
+ # "no blockers" for it would claim a clean bill of health that was never
50
+ # established, so it is reported separately.
51
+ NOT_INSPECTABLE_MESSAGE = "lockfile has no registry-resolved pins; nothing inspectable"
52
+ NOT_REMOVABLE_PREFIX = "blockers pinned but not installed in a gem home (source checkout, not removable): "
53
+
54
+ include GemUninstallSupport
55
+
56
+ def initialize(members:, execute: false, runner: nil)
57
+ @members = members
58
+ @execute = execute
59
+ @runner = runner || CommandRunner.new(execute: execute)
60
+ end
61
+
62
+ # @return [Array<CommandResult>]
63
+ def results
64
+ findings = members.map { |member| member_blockers(member) }
65
+ removable = removable_candidates(findings)
66
+ annotated = annotate_contributors(findings, removable)
67
+ informational = informational_results(annotated)
68
+ return informational if removable.empty?
69
+
70
+ informational + batched_cleanup_results(annotated, removable)
71
+ end
72
+
73
+ private
74
+
75
+ attr_reader :members, :execute, :runner
76
+
77
+ def uninstall_phase
78
+ PHASE
79
+ end
80
+
81
+ # One member's blockers, from the check's structured diagnostics.
82
+ #
83
+ # @return [Hash] member, blockers (deduped [name, version, remote]),
84
+ # inspectable (whether the lockfile had registry pins at all)
85
+ def member_blockers(member)
86
+ lockfile = File.join(member.root, "Gemfile.lock")
87
+ return {member: member, blockers: [], inspectable: false} unless File.file?(lockfile)
88
+
89
+ pins = registry_pins(lockfile)
90
+ result = PublishedVersionCheck.call(member: member)
91
+ blockers = result.diagnostics
92
+ .select { |diagnostic| diagnostic["kind"] == BLOCKER_KIND }
93
+ .map { |diagnostic| [diagnostic["gem"], diagnostic["version"], diagnostic["remote"]] }
94
+ .uniq
95
+
96
+ {member: member, blockers: blockers, inspectable: !pins.empty?}
97
+ end
98
+
99
+ def registry_pins(lockfile)
100
+ Kettle::Dev::LockfileReset.registry_gem_specs_from_source(File.read(lockfile))
101
+ rescue
102
+ []
103
+ end
104
+
105
+ # Blockers that are actually installed in a gem home and can therefore be
106
+ # removed, deduplicated across members by [name, version].
107
+ #
108
+ # A blocker that is not installed in a gem home is a PATH or git source
109
+ # checkout: `gem uninstall` cannot remove it, and including one would abort
110
+ # the whole batch. Those are reported rather than attempted.
111
+ #
112
+ # The version is normalized by stripping any platform suffix, because a
113
+ # lockfile pins `nokogiri (1.19.4-x86_64-linux-gnu)` while gem homes store
114
+ # the spec as `nokogiri-1.19.4-x86_64-linux-gnu.gemspec` and
115
+ # #installed_versions records it as 1.19.4.
116
+ def removable_candidates(findings)
117
+ findings.flat_map { |finding| normalized_blockers(finding) }
118
+ .uniq
119
+ .select { |name, version| installed_versions(name).include?(version) }
120
+ end
121
+
122
+ # A finding's blockers as [name, Gem::Version] pairs, minus any whose version
123
+ # cannot be parsed. Shared by #removable_candidates and #annotate_contributors
124
+ # so both see identical pairs and cannot drift apart.
125
+ def normalized_blockers(finding)
126
+ finding.fetch(:blockers).filter_map do |name, version, _remote|
127
+ normalized = normalize_version(version)
128
+ [name, normalized] unless normalized.nil?
129
+ end
130
+ end
131
+
132
+ # Strips any platform suffix and parses, rejecting blanks.
133
+ #
134
+ # The blank guard matters: `Gem::Version.new("")` returns version "0" rather
135
+ # than raising, so an empty version from a malformed diagnostic would become
136
+ # a bogus `name:0` uninstall argument instead of being skipped.
137
+ #
138
+ # @return [Gem::Version, nil]
139
+ def normalize_version(version)
140
+ text = version.to_s.split("-", 2).first.to_s.strip
141
+ return nil if text.empty?
142
+
143
+ parse_gem_version(text)
144
+ end
145
+
146
+ # Adds `:own` — the subset of the global removable set this member pinned.
147
+ #
148
+ # Computed once here rather than on demand: three call sites need it
149
+ # (host selection, contributing filter, per-member reporting), and each
150
+ # would otherwise re-run the same normalization and membership test.
151
+ def annotate_contributors(findings, removable)
152
+ findings.map do |finding|
153
+ own = normalized_blockers(finding).select { |candidate| removable.include?(candidate) }.uniq
154
+ finding.merge(own: own)
155
+ end
156
+ end
157
+
158
+ # Per-member reporting. Members that contributed removable blockers get the
159
+ # shared batch outcome; the rest get a plain informational result.
160
+ def informational_results(annotated)
161
+ annotated.filter_map do |finding|
162
+ next if finding.fetch(:own).any?
163
+
164
+ member = finding.fetch(:member)
165
+ message = if finding.fetch(:blockers).any?
166
+ unremovable_blockers_message(finding)
167
+ elsif finding.fetch(:inspectable)
168
+ NO_BLOCKERS_MESSAGE
169
+ else
170
+ NOT_INSPECTABLE_MESSAGE
171
+ end
172
+ informational_result(member: member, message: message)
173
+ end
174
+ end
175
+
176
+ def contributors(annotated)
177
+ annotated.select { |finding| finding.fetch(:own).any? }
178
+ end
179
+
180
+ # Names the blockers this member pinned that cannot be removed here, and
181
+ # why, so a cross-family offender is attributable rather than mysterious.
182
+ def unremovable_blockers_message(finding)
183
+ names = finding.fetch(:blockers).map { |name, version, _remote| "#{name} #{version}" }
184
+ "#{NOT_REMOVABLE_PREFIX}#{names.join(", ")}"
185
+ end
186
+
187
+ # Runs the batch once, then reports that one outcome against every member
188
+ # that contributed to it. See the class comment for why a single batched
189
+ # invocation is required rather than one per gem.
190
+ def batched_cleanup_results(annotated, removable)
191
+ contributing = contributors(annotated)
192
+ # Defensive: `removable` is derived from findings' own blockers, so a
193
+ # non-empty set always has at least one contributor. Guarded anyway
194
+ # because a nil host would raise NoMethodError inside the uninstall.
195
+ host = contributing.first&.fetch(:member)
196
+ return [] if host.nil?
197
+
198
+ command = batched_uninstall_command(removable)
199
+ # gem uninstall does not depend on the working directory, so the first
200
+ # contributing member provides a real directory to execute in.
201
+ outcome = if execute
202
+ verified_uninstall_outcome(candidates: removable, host: host, command: command)
203
+ else
204
+ dry_run_uninstall_outcome(command: command)
205
+ end
206
+
207
+ contributing.map { |finding| member_result(finding, outcome) }
208
+ end
209
+
210
+ # One member's view of the shared batch outcome, naming the gems that member
211
+ # pinned so a failure is attributable rather than mysterious.
212
+ #
213
+ # The member's own contribution comes from the annotated finding:
214
+ # PublishedVersionCheck hits the registry, so re-invoking it during
215
+ # reporting would add a network round-trip per member.
216
+ def member_result(finding, outcome)
217
+ member = finding.fetch(:member)
218
+ own = finding.fetch(:own)
219
+ own_reason = describe_gem_versions(own)
220
+ reason = if outcome.ok? || own.empty?
221
+ outcome.reason
222
+ else
223
+ "batched gem uninstall including #{own_reason} failed"
224
+ end
225
+ outcome.dup.tap do |copy|
226
+ copy.member_name = member.name
227
+ copy.workdir = member.root
228
+ copy.stdout = outcome.skipped ? "would uninstall #{own_reason}" : outcome.stdout
229
+ copy.reason = reason
230
+ end
231
+ end
232
+
233
+ def informational_result(member:, message:)
234
+ CommandResult.new(
235
+ member_name: member.name,
236
+ phase: PHASE,
237
+ command: ["internal", PHASE],
238
+ workdir: member.root,
239
+ status: 0,
240
+ success: true,
241
+ stdout: message,
242
+ stderr: "",
243
+ elapsed_seconds: 0.0,
244
+ skipped: false,
245
+ reason: nil
246
+ )
247
+ end
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Family
5
+ # Uninstalls exactly what `install` installs: each selected member's current
6
+ # source version, plus the cached .gem files.
7
+ #
8
+ # This is the deterministic inverse of `install`, and it exists because the
9
+ # other two cleanups cannot see this class of pollution:
10
+ #
11
+ # - +UnreleasedGemCleanup+ (`clean-unreleased`) removes installed versions
12
+ # newer than the latest release, but it derives candidates per member from
13
+ # release state and can miss versions it judges released.
14
+ # - +BlockerCleanup+ (`clean-blockers`) removes what members' lockfiles pin,
15
+ # so an installed-but-unpinned gem is invisible to it. Observed live:
16
+ # commonmarker-merge 7.1.10 survived a blocker cleanup (no release
17
+ # lockfile pinned it), then broke gem activation family-wide with its
18
+ # dangling exact pin on markdown-merge = 7.1.10 once the rest of the
19
+ # series was removed.
20
+ #
21
+ # Candidates are the members `install` would act on -- the selected members
22
+ # plus any configured install local_dependencies -- restricted to those whose
23
+ # source version is unreleased (a member already at its released version has
24
+ # nothing to remove; uninstalling it would only churn the bundles that
25
+ # resolve it). Release state comes from the same ReleaseStateCheck
26
+ # +UnreleasedGemCleanup+ uses.
27
+ #
28
+ # The uninstall itself is the shared GemUninstallSupport batch: one
29
+ # invocation, a documented --ignore-dependencies retry when an out-of-batch
30
+ # dependent aborts it, cache removal, and post-uninstall re-enumeration.
31
+ #
32
+ # Note for the family running it: when this command is executed from an
33
+ # installed kettle-family, the batch includes kettle-family itself. The
34
+ # uninstall runs in a subprocess, so self-removal mid-run is safe; the
35
+ # executable falls back to the latest released install afterwards.
36
+ class CleanInstalled
37
+ PHASE = "clean_installed"
38
+ RELEASED_MESSAGE = "source version is released; nothing to uninstall"
39
+ NOT_INSTALLED_MESSAGE = "source version is not installed"
40
+ UNKNOWN_VERSION_MESSAGE = "source version is unknown; nothing to uninstall"
41
+ UNKNOWN_RELEASED_MESSAGE = "latest released version is unknown; no cleanup attempted"
42
+
43
+ include GemUninstallSupport
44
+ include MemberLoader
45
+
46
+ def initialize(config:, members:, execute: false, runner: nil)
47
+ @config = config
48
+ @members = members
49
+ @execute = execute
50
+ @runner = runner || CommandRunner.new(execute: execute)
51
+ end
52
+
53
+ # @return [Array<CommandResult>]
54
+ def results
55
+ findings = member_findings
56
+ removable = findings.filter_map { |finding| finding[:candidate] }.uniq
57
+ annotated = findings.map do |finding|
58
+ candidate = finding[:candidate]
59
+ own = (candidate && removable.include?(candidate)) ? [candidate] : []
60
+ finding.merge(own: own)
61
+ end
62
+ informational = annotated.filter_map { |finding| informational_for(finding) }
63
+ return informational if removable.empty?
64
+
65
+ informational + batched_cleanup_results(annotated.select { |finding| finding[:own].any? }, removable)
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :config, :members, :execute, :runner
71
+
72
+ def uninstall_phase
73
+ PHASE
74
+ end
75
+
76
+ # The members `install` would act on: selected members plus configured
77
+ # install local_dependencies, deduplicated by name.
78
+ def candidate_members
79
+ local = config.install_local_dependencies.map { |path| member_from_path(path) }
80
+ seen = {}
81
+ (local + members).each_with_object([]) do |member, memo|
82
+ next if seen.key?(member.name)
83
+
84
+ seen[member.name] = true
85
+ memo << member
86
+ end
87
+ end
88
+
89
+ def release_state_by_member
90
+ ReleaseStateCheck.new(config: config, members: candidate_members).results.each_with_object({}) do |result, memo|
91
+ memo[result.member_name] = result
92
+ end
93
+ end
94
+
95
+ # One member's candidate: its source version, when that version is
96
+ # unreleased and installed. [name, Gem::Version] or nil, plus the reason
97
+ # there is nothing to do.
98
+ def member_findings
99
+ states = release_state_by_member
100
+ candidate_members.map do |member|
101
+ state = states[member.name]
102
+ candidate, message = candidate_for(member, state)
103
+ {member: member, candidate: candidate, message: message, state: state}
104
+ end
105
+ end
106
+
107
+ def candidate_for(member, state)
108
+ return [nil, nil] unless state&.ok?
109
+
110
+ latest = latest_released_version(state.state)
111
+ return [nil, nil] unless latest
112
+
113
+ source = parse_gem_version(member.version.to_s)
114
+ return [nil, UNKNOWN_VERSION_MESSAGE] unless source
115
+ return [nil, RELEASED_MESSAGE] unless source > latest
116
+ return [[member.name, source], nil] if installed_versions(member.name).include?(source)
117
+
118
+ [nil, NOT_INSTALLED_MESSAGE]
119
+ end
120
+
121
+ def latest_released_version(state)
122
+ value = state.fetch("latest_released", nil).to_s
123
+ return nil if value.empty? || value == "unknown"
124
+
125
+ Gem::Version.new(value.delete_prefix("v"))
126
+ rescue ArgumentError
127
+ nil
128
+ end
129
+
130
+ # Per-member outcomes that are not part of the batch: unreadable release
131
+ # state (a failure naming its member), or nothing to do (informational).
132
+ def informational_for(finding)
133
+ member = finding.fetch(:member)
134
+ state = finding[:state]
135
+ return release_state_failure(member: member, release_state: state) unless state&.ok?
136
+
137
+ message = finding[:message]
138
+ return nil if message.nil? && finding[:own].any?
139
+ return informational_result(member: member, message: UNKNOWN_RELEASED_MESSAGE) if message.nil?
140
+
141
+ informational_result(member: member, message: message)
142
+ end
143
+
144
+ # Runs the batch once, then reports that one outcome against every member
145
+ # whose gem was in it, mirroring UnreleasedGemCleanup: the command and
146
+ # exit state are shared, but each member reports only its own gem so a
147
+ # failure stays attributable.
148
+ def batched_cleanup_results(contributors, removable)
149
+ host = contributors.first.fetch(:member)
150
+ command = batched_uninstall_command(removable)
151
+ outcome = if execute
152
+ verified_uninstall_outcome(candidates: removable, host: host, command: command)
153
+ else
154
+ dry_run_uninstall_outcome(command: command)
155
+ end
156
+
157
+ contributors.map do |finding|
158
+ member_result(finding.fetch(:member), outcome, finding.fetch(:own))
159
+ end
160
+ end
161
+
162
+ def member_result(member, outcome, own_candidates)
163
+ own = describe_gem_versions(own_candidates)
164
+ reason = if outcome.ok? || own_candidates.empty?
165
+ outcome.reason
166
+ else
167
+ "batched gem uninstall including #{own} failed"
168
+ end
169
+ outcome.dup.tap do |copy|
170
+ copy.member_name = member.name
171
+ copy.workdir = member.root
172
+ copy.stdout = outcome.skipped ? "would uninstall #{own}" : outcome.stdout
173
+ copy.reason = reason
174
+ end
175
+ end
176
+
177
+ def informational_result(member:, message:)
178
+ CommandResult.new(
179
+ member_name: member.name,
180
+ phase: PHASE,
181
+ command: ["internal", PHASE],
182
+ workdir: member.root,
183
+ status: 0,
184
+ success: true,
185
+ stdout: message,
186
+ stderr: "",
187
+ elapsed_seconds: 0.0,
188
+ skipped: false,
189
+ reason: nil
190
+ )
191
+ end
192
+
193
+ def release_state_failure(member:, release_state:)
194
+ CommandResult.new(
195
+ member_name: member.name,
196
+ phase: PHASE,
197
+ command: ["internal", PHASE],
198
+ workdir: member.root,
199
+ status: release_state&.status || 1,
200
+ success: false,
201
+ stdout: "",
202
+ stderr: release_state&.stderr.to_s,
203
+ elapsed_seconds: 0.0,
204
+ skipped: false,
205
+ reason: "release state unavailable"
206
+ )
207
+ end
208
+ end
209
+ end
210
+ end
@@ -11,7 +11,7 @@ module Kettle
11
11
  class CLI < CommandKit::Command
12
12
  include CommandKit::Commands
13
13
 
14
- COMMANDS = %w[version mise-trust discover plan report metadata check clean-unreleased reconcile-releases reset test lint docs template gha-sha-pins bup bupb update-bundler bex install bump bump-version add-changelog release push pull sync up branch-lanes release-state state].freeze
14
+ COMMANDS = %w[version mise-trust discover plan report metadata check clean-blockers clean-installed clean-unreleased reconcile-releases reset test lint docs template gha-sha-pins bup bupb update-bundler bex install bump bump-version add-changelog release push pull sync up branch-lanes release-state state].freeze
15
15
  WORKFLOW_COMMANDS = %w[check reset test lint docs template gha-sha-pins bup bupb update-bundler bex release push pull sync up].freeze
16
16
 
17
17
  command_name "kettle-family"
@@ -353,6 +353,18 @@ module Kettle
353
353
  description "Uninstall locally installed family gem versions newer than the latest released version."
354
354
  end
355
355
 
356
+ class CleanBlockers < WorkflowCommand
357
+ command_name "clean-blockers"
358
+ usage "[options]"
359
+ description "Uninstall locally installed gems, from any family, whose unreleased versions are pinned by members' lockfiles."
360
+ end
361
+
362
+ class CleanInstalled < WorkflowCommand
363
+ command_name "clean-installed"
364
+ usage "[options]"
365
+ description "Uninstall the exact gem versions install installs: each member's current source version, when unreleased, including cached .gem files."
366
+ end
367
+
356
368
  class ReconcileReleases < BaseCommand
357
369
  include ExecutionOptions
358
370
 
@@ -657,6 +669,8 @@ module Kettle
657
669
  command "report", ReportCommand
658
670
  command Metadata
659
671
  command Check
672
+ command CleanBlockers
673
+ command CleanInstalled
660
674
  command CleanUnreleased
661
675
  command ReconcileReleases
662
676
  command Reset
@@ -715,6 +729,7 @@ module Kettle
715
729
  def build_report(command, options)
716
730
  config = Config.load(root: options[:root], path: options[:config])
717
731
  start_at = parse_start_at(options[:start_at])
732
+ Selection.validate_positional_start_at!(command, start_at.member)
718
733
  effective_only = default_only_filter(command: command, only: options[:only])
719
734
  discovery = Discovery.new(
720
735
  config: config,
@@ -869,6 +884,8 @@ module Kettle
869
884
  return bump_version_results(config: config, members: members, options: options, phase: command, release_state_results: release_state_results) if %w[bump bump-version].include?(command)
870
885
  return add_changelog_results(members: members, options: options) if command == "add-changelog"
871
886
  return clean_unreleased_results(config: config, members: members, options: options) if command == "clean-unreleased"
887
+ return clean_blockers_results(members: members, options: options) if command == "clean-blockers"
888
+ return clean_installed_results(config: config, members: members, options: options) if command == "clean-installed"
872
889
  return reconcile_release_results(config: config, members: members, options: options) if command == "reconcile-releases"
873
890
  return branch_lane_results(config: config, members: members) if command == "branch-lanes"
874
891
  return release_state_results(config: config, members: members, jobs: options[:jobs], event_handler: state_event_handler) if command == "release-state"
@@ -1360,6 +1377,14 @@ module Kettle
1360
1377
  UnreleasedGemCleanup.new(config: config, members: members, execute: options[:execute]).results
1361
1378
  end
1362
1379
 
1380
+ def clean_blockers_results(members:, options:)
1381
+ BlockerCleanup.new(members: members, execute: options[:execute]).results
1382
+ end
1383
+
1384
+ def clean_installed_results(config:, members:, options:)
1385
+ CleanInstalled.new(config: config, members: members, execute: options[:execute]).results
1386
+ end
1387
+
1363
1388
  def reconcile_release_results(config:, members:, options:)
1364
1389
  ReleaseReconciler.new(config: config, members: members, execute: options[:execute]).results
1365
1390
  end