Skip to content

Commit ce93811

Browse files
hsbtclaude
andcommitted
Summarize cooldown-skipped versions at the end of install, update, and lock
When cooldown silently resolves an older version, users comparing environments with different cooldown settings can't tell why they got different versions. Collect the versions the resolver excluded, and after a successful resolution print the newest skipped version per gem once the command finishes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 203d1bf commit ce93811

7 files changed

Lines changed: 155 additions & 1 deletion

File tree

lib/bundler/cli/common.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ def self.print_post_install_message(name, msg)
2020
Bundler.ui.info msg
2121
end
2222

23+
def self.output_cooldown_skipped_summary(definition = Bundler.definition)
24+
skipped = definition.cooldown_skipped
25+
return if skipped.empty?
26+
27+
Bundler.ui.info "The following gem versions were skipped by the cooldown setting:"
28+
skipped.each do |entry|
29+
days = entry[:available_in_days]
30+
Bundler.ui.info " * #{entry[:name]} #{entry[:version]} (available in #{days} #{days == 1 ? "day" : "days"}), resolved #{entry[:resolved]} instead"
31+
end
32+
end
33+
2334
def self.output_fund_metadata_summary
2435
return if Bundler.settings["ignore_funding_requests"]
2536
definition = Bundler.definition

lib/bundler/cli/install.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def run
6464
end
6565

6666
Bundler::CLI::Common.output_post_install_messages installer.post_install_messages
67+
Bundler::CLI::Common.output_cooldown_skipped_summary(definition)
6768

6869
if CLI::Common.clean_after_install?
6970
require_relative "clean"

lib/bundler/cli/lock.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def run
7777
puts "Writing lockfile to #{file}"
7878
definition.write_lock(file, false)
7979
end
80+
81+
Bundler::CLI::Common.output_cooldown_skipped_summary(definition)
8082
end
8183

8284
Bundler.ui.output_stream = previous_output_stream

lib/bundler/cli/update.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def run
126126
Bundler.ui.confirm "Bundle updated!"
127127
Bundler::CLI::Common.output_without_groups_message(:update)
128128
Bundler::CLI::Common.output_post_install_messages installer.post_install_messages
129+
Bundler::CLI::Common.output_cooldown_skipped_summary
129130

130131
Bundler::CLI::Common.output_fund_metadata_summary
131132
end

lib/bundler/definition.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,12 @@ def spec_git_paths
372372
sources.git_sources.filter_map {|s| File.realpath(s.path) if File.exist?(s.path) }
373373
end
374374

375+
# Versions excluded by cooldown during the last resolution, one entry per
376+
# gem with the newest skipped version. Empty when no resolution ran.
377+
def cooldown_skipped
378+
@cooldown_skipped || []
379+
end
380+
375381
def groups
376382
dependencies.flat_map(&:groups).uniq
377383
end
@@ -783,6 +789,8 @@ def start_resolution
783789

784790
result = SpecSet.new(resolver.start)
785791

792+
@cooldown_skipped = resolver.cooldown_skipped
793+
786794
@resolved_bundler_version = result.find {|spec| spec.name == "bundler" }&.version
787795

788796
@new_platforms.each do |platform|

lib/bundler/resolver.rb

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,15 @@ class Resolver
1414
require_relative "resolver/root"
1515
require_relative "resolver/strategy"
1616

17+
attr_reader :cooldown_skipped
18+
1719
def initialize(base, gem_version_promoter, most_specific_locked_platform = nil)
1820
@source_requirements = base.source_requirements
1921
@base = base
2022
@gem_version_promoter = gem_version_promoter
2123
@most_specific_locked_platform = most_specific_locked_platform
24+
@cooldown_skipped = []
25+
@cooldown_skipped_specs = {}
2226
end
2327

2428
def start
@@ -36,6 +40,8 @@ def setup_solver
3640
root = Resolver::Root.new(name_for_explicit_dependency_source)
3741
root_version = Resolver::Candidate.new(0)
3842

43+
@cooldown_skipped_specs = {}
44+
3945
@all_specs = Hash.new do |specs, name|
4046
source = source_for(name)
4147
matches = source.specs.search(name)
@@ -83,7 +89,9 @@ def solve_versions(root:, logger:)
8389
result = solver.solve
8490
resolved_specs = result.flat_map {|package, version| version.to_specs(package, @most_specific_locked_platform) }
8591
Override.attach(resolved_specs, @base.overrides)
86-
SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs)
92+
spec_set = SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs)
93+
@cooldown_skipped = cooldown_skipped_summary(spec_set)
94+
spec_set
8795
rescue Gem::PubGrub::SolveFailure => e
8896
incompatibility = e.incompatibility
8997

@@ -447,6 +455,7 @@ def cooldown_excluded_versions(specs)
447455
specs.each do |spec|
448456
next unless cooldown_excluded?(spec)
449457
excluded[[spec.name, spec.version]] = true
458+
(@cooldown_skipped_specs ||= {})[[spec.name, spec.version]] ||= spec
450459
end
451460
excluded
452461
end
@@ -489,6 +498,50 @@ def cooldown_now
489498
@cooldown_now ||= Time.now
490499
end
491500

501+
# Reports, per gem, the newest version that cooldown kept out of a
502+
# successful resolution. A skipped version is only worth reporting when it
503+
# is newer than the version actually resolved and satisfies every
504+
# requirement the final resolution places on that gem, so we don't claim a
505+
# version the resolver could never have picked anyway.
506+
def cooldown_skipped_summary(spec_set)
507+
return [] if @cooldown_skipped_specs.empty?
508+
509+
requirements = Hash.new {|h, name| h[name] = [] }
510+
@requirements.each {|dep| requirements[dep.name] << dep.requirement }
511+
spec_set.each do |spec|
512+
spec.dependencies.each {|dep| requirements[dep.name] << dep.requirement }
513+
end
514+
515+
resolved_versions = {}
516+
spec_set.each do |spec|
517+
version = resolved_versions[spec.name]
518+
resolved_versions[spec.name] = spec.version if version.nil? || spec.version > version
519+
end
520+
521+
newest_skipped = {}
522+
@cooldown_skipped_specs.each do |(name, version), spec|
523+
resolved = resolved_versions[name]
524+
next unless resolved && version > resolved
525+
next unless requirements[name].all? {|req| req.satisfied_by?(version) }
526+
newest = newest_skipped[name]
527+
newest_skipped[name] = spec if newest.nil? || version > newest.version
528+
end
529+
530+
newest_skipped.values.sort_by(&:name).map do |spec|
531+
{
532+
name: spec.name,
533+
version: spec.version,
534+
resolved: resolved_versions[spec.name],
535+
available_in_days: remaining_cooldown_days(spec),
536+
}
537+
end
538+
end
539+
540+
def remaining_cooldown_days(spec)
541+
remaining = (spec.remote.effective_cooldown * 86_400) - (cooldown_now - spec.created_at)
542+
[(remaining / 86_400.0).ceil, 1].max
543+
end
544+
492545
def filter_remote_specs(specs, package)
493546
if package.prefer_local?
494547
local_specs = specs.select {|s| s.is_a?(StubSpecification) }

spec/install/cooldown_spec.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,84 @@
188188
expect(the_bundle).to include_gems("ripe_gem 2.0.0")
189189
end
190190

191+
it "summarizes skipped versions at the end of bundle install" do
192+
gemfile <<-G
193+
source "https://gem.repo3"
194+
gem "ripe_gem"
195+
G
196+
197+
bundle "install --cooldown 7", artifice: "compact_index_cooldown"
198+
199+
expect(out).to include("The following gem versions were skipped by the cooldown setting:")
200+
expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead")
201+
expect(the_bundle).to include_gems("ripe_gem 1.0.0")
202+
end
203+
204+
it "summarizes skipped versions at the end of bundle update" do
205+
gemfile <<-G
206+
source "https://gem.repo3", cooldown: 7
207+
gem "ripe_gem"
208+
G
209+
210+
lockfile <<-L
211+
GEM
212+
remote: https://gem.repo3/
213+
specs:
214+
ripe_gem (1.0.0)
215+
216+
PLATFORMS
217+
#{lockfile_platforms}
218+
219+
DEPENDENCIES
220+
ripe_gem
221+
222+
BUNDLED WITH
223+
#{Bundler::VERSION}
224+
L
225+
226+
bundle "update ripe_gem", artifice: "compact_index_cooldown"
227+
228+
expect(out).to include("The following gem versions were skipped by the cooldown setting:")
229+
expect(out).to include("* ripe_gem 2.0.0 (available in 6 days), resolved 1.0.0 instead")
230+
expect(the_bundle).to include_gems("ripe_gem 1.0.0")
231+
end
232+
233+
it "does not print a skip summary when cooldown is disabled" do
234+
gemfile <<-G
235+
source "https://gem.repo3"
236+
gem "ripe_gem"
237+
G
238+
239+
bundle "install --cooldown 0", artifice: "compact_index_cooldown"
240+
241+
expect(out).not_to include("skipped by the cooldown setting")
242+
end
243+
244+
it "does not print a skip summary for versions the Gemfile requirement rejects anyway" do
245+
gemfile <<-G
246+
source "https://gem.repo3"
247+
gem "ripe_gem", "~> 1.0"
248+
G
249+
250+
bundle "install --cooldown 7", artifice: "compact_index_cooldown"
251+
252+
expect(out).not_to include("skipped by the cooldown setting")
253+
expect(the_bundle).to include_gems("ripe_gem 1.0.0")
254+
end
255+
256+
it "does not print a skip summary when installing from an up-to-date lockfile" do
257+
gemfile <<-G
258+
source "https://gem.repo3", cooldown: 7
259+
gem "ripe_gem"
260+
G
261+
262+
bundle "install", artifice: "compact_index_cooldown"
263+
expect(out).to include("skipped by the cooldown setting")
264+
265+
bundle "install", artifice: "compact_index_cooldown"
266+
expect(out).not_to include("skipped by the cooldown setting")
267+
end
268+
191269
it "applies cooldown declared per-source in the Gemfile" do
192270
gemfile <<-G
193271
source "https://gem.repo3", cooldown: 7

0 commit comments

Comments
 (0)