kettle-family 1.3.7 → 1.3.10

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: ec4f59e4397fe55738de3f0e3e482a2053d3c771716e9d94de40478f76ba80ae
4
- data.tar.gz: 7ea569f07fa6cc8cc46cf5351cf93441ece85870c80ab51aed2458e0ce66346a
3
+ metadata.gz: 2768f1b7d61be1a362ef37070da39c0b5f0a6b6ea4137fa4c157e3eeaf3631ca
4
+ data.tar.gz: 43142009ed7af74c25116bef27ae083059c0d3afb8dbcc820180cdc47b9b13fd
5
5
  SHA512:
6
- metadata.gz: 9dac940565396b2209bd67e957af7f2e015318d70572c05cca684e8de26c43d75755a142727d994baa5fbd646ce674fa7ab6c84bdb913ca9c6685824fd3beca6
7
- data.tar.gz: 53c75a31e0896365a67c7b0ec46a45830d1e71abe37843dc715ee7f086f39166fbf39c36620efc0760d00848a6ce11238da0ee81cf84cfa6ced5e192e13178e4
6
+ metadata.gz: 3f5866f4a83b7783ee2607b64e208c6b10d7686dbc7121178b8d578e5d7f928c36e50e5a608634d11b8d879eb45317a10101372a82573e7a7d7d46c65d76c12b
7
+ data.tar.gz: 0f71c5f7b0c8705741a69870a937cfa994f6524bb4dc4922fcd6b69f4a84206b6093f4ec4ff949ad21e3854903bd45fb968cd30be994fcd3de617f37e86332e9
checksums.yaml.gz.sig CHANGED
@@ -1,2 +1 @@
1
- O�PCn��
2
- � 颍FH?=�IՈA���ًq$���C0D�qϱ�V�Od�{�q�n?g��z���A�0� ��f�H�3�0Eϓ�=5N�8��̺Z�Cz�|T\������&t��P�B�fKU�v�'��Qoۮ;�Y�԰^hՏN�p��O���Iݑ���#�g��N|/�$��G��f���1CQG�[/s{&�N�WьE�r�3a{���q��%���;� �kZHI��1�! �����2�*6�֦�jO��I?���e�ɻ�����O9��L�Je8;kQ����5�xzRM���0���-���|%#�:/�ꁞ!re��%�L�v��Vߒ����@<�ʧ�Rld+�U*s>�����C��Ak
1
+ +�"��#ә��=���*@巨����d�M`�NEю4�'e��0����ׅ^��t��A�Z��P1�(%� ��J&]��;KçeJ���"E��$P���[/���=�u���7Ё=4��fa����:�k�a��{b%��E4�i)�Q��x��|�+�/-Ȫ+CR��y��L��`Ƽp�S@n��8���2�H�ul���q44��f�+af���/� ⪾a��������x��U^�����~�M �pG@E6�Z�O��.�Q�+�o����V�R����S���o�������b����� �"">�#�h;~�L���y�t8�D;y4  ��JUQ�򒠜�L�4���~-�R�$�����W5���p�{d&P�F
data/CHANGELOG.md CHANGED
@@ -30,6 +30,52 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.3.10] - 2026-10-11
34
+
35
+ - TAG: [v1.3.10][1.3.10t]
36
+ - COVERAGE: 93.42% -- 7072/7570 lines in 43 files
37
+ - BRANCH COVERAGE: 77.96% -- 2837/3639 branches in 43 files
38
+ - 35.42% documented
39
+
40
+ ### Fixed
41
+
42
+ - Family release now tapes every child and aggregate event
43
+
44
+ - the release command creates a ReleaseStateEventTape in execute mode and relays command_step, github_release, secret_provider and ci_monitor child events plus aggregate github_release events into it with the member attached; the tape preserves origin event type instead of rewriting it to release_state
45
+
46
+ ## [1.3.9] - 2026-10-10
47
+
48
+ - TAG: [v1.3.9][1.3.9t]
49
+ - COVERAGE: 93.43% -- 7068/7565 lines in 43 files
50
+ - BRANCH COVERAGE: 77.95% -- 2832/3633 branches in 43 files
51
+ - 35.42% documented
52
+
53
+ ### Added
54
+
55
+ - 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.
56
+
57
+ - 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.
58
+
59
+ - 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.
60
+
61
+ ### Changed
62
+
63
+ - [kc] kettle-jem/prepare: updated 2 project files:
64
+ - dependencies (2)
65
+
66
+ - [kc] kettle-jem/template: updated 3 project files:
67
+ - code and tests (1)
68
+ - documentation (1)
69
+ - other (1)
70
+
71
+ ### Fixed
72
+
73
+ - 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.
74
+
75
+ - 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.
76
+
77
+ - 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.
78
+
33
79
  ## [1.3.7] - 2026-10-09
34
80
 
35
81
  - TAG: [v1.3.7][1.3.7t]
@@ -2931,7 +2977,11 @@ Please file a bug if you notice a violation of semantic versioning.
2931
2977
  - Fixed CI load failures on engines without compatible `pty` support by falling back to Open3 for interactive release commands.
2932
2978
  - Fixed Ruby 3.2 version-bump support by loading Prism lazily and wiring the Prism gem only for MRI versions that need it.
2933
2979
 
2934
- [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.7...HEAD
2980
+ [Unreleased]: https://github.com/kettle-dev/kettle-family/compare/v1.3.10...HEAD
2981
+ [1.3.10]: https://github.com/kettle-dev/kettle-family/compare/v1.3.9...v1.3.10
2982
+ [1.3.10t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.10
2983
+ [1.3.9]: https://github.com/kettle-dev/kettle-family/compare/v1.3.7...v1.3.9
2984
+ [1.3.9t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.9
2935
2985
  [1.3.7]: https://github.com/kettle-dev/kettle-family/compare/v1.3.6...v1.3.7
2936
2986
  [1.3.7t]: https://github.com/kettle-dev/kettle-family/releases/tag/v1.3.7
2937
2987
  [1.3.6]: https://github.com/kettle-dev/kettle-family/compare/v1.3.5...v1.3.6
data/README.md CHANGED
@@ -501,6 +501,8 @@ The command names below are available from `kettle-family --help`:
501
501
  | `metadata` | Print member version, Ruby floor, license, and author metadata. |
502
502
  | `check` | Run internal read-only readiness checks. |
503
503
  | `clean-unreleased` | Uninstall locally installed family versions newer than released versions. |
504
+ | `clean-blockers` | Uninstall unreleased gems, from any family, pinned by members' lockfiles — removes cross-family blockers such as an unreleased StructuredMerge gem pinned by a kettle-dev member. |
505
+ | `clean-installed` | The deterministic inverse of `install`: uninstall each selected member's current source version (plus configured install local dependencies) when that version is unreleased, including cached `.gem` files. Sees installed-but-unpinned pollution the other two cleanups miss. |
504
506
  | `reconcile-releases` | Create verified missing GitHub Releases for published tags. |
505
507
  | `reset` | Reset a managed family artifact, such as release lockfiles. |
506
508
  | `test` | Run the configured test workflow for each selected member. |
@@ -751,6 +753,22 @@ The cleanup command compares installed local gem versions with each member's
751
753
  latest released version and uninstalls only versions newer than the release
752
754
  state reports.
753
755
 
756
+ Two companion cleanups cover what `clean-unreleased` cannot see:
757
+
758
+ ```console
759
+ kettle-family clean-blockers --execute
760
+ kettle-family clean-installed --execute
761
+ ```
762
+
763
+ `clean-blockers` targets cross-family pollution: unreleased gems from any
764
+ family whose versions are pinned by members' lockfiles (e.g. an unreleased
765
+ StructuredMerge gem pinned by a kettle-dev member). `clean-installed` is the
766
+ deterministic inverse of `install` — it uninstalls a selected member's own
767
+ current source version, and the local dependencies `install` covers, whenever
768
+ that version is unreleased, including cached `.gem` files. Use it when an
769
+ installed-but-unpinned version survives both other cleanups and breaks gem
770
+ activation with a dangling exact pin.
771
+
754
772
  If a member lockfile has captured local sibling paths or checksum-less gem
755
773
  versions from local development, reset the selected members' `Gemfile.lock`
756
774
  files:
@@ -1192,7 +1210,7 @@ Thanks for RTFM. ☺️
1192
1210
  [📌gitmoji]: https://gitmoji.dev
1193
1211
  [📌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
1212
  [🧮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
1213
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-7.570-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
1196
1214
  [🔐security]: https://github.com/kettle-dev/kettle-family/blob/main/SECURITY.md
1197
1215
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
1198
1216
  [📄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