Skip to content

Commit 3920a09

Browse files
hsbtclaude
andcommitted
Apply cooldown to locally installed gem versions
`Source::Rubygems#specs` merges `installed_specs` on top of `remote_specs`, so a `Bundler::StubSpecification` for an already-installed gem overwrites the matching `EndpointSpecification` and erases its `created_at`. The cooldown filter then short-circuited on `spec.respond_to?(:created_at)` and let the local stub through, which made `bundle install --cooldown N` keep selecting a brand-new version that happened to be on disk already. Snapshot the remote `created_at` per `[name, version]` before merging and back-fill it onto stubs that lack one, attaching the source's first remote so `effective_cooldown` is reachable. The filter now runs ahead of `filter_remote_specs` and rejects every spec that shares an `[name, version]` flagged by `cooldown_excluded?`, so a stub and the endpoint that carries its date drop together. `RemoteSpecification` gains `attr_accessor :created_at` so any subclass without an explicit setter participates. `spec/bundler/resolver/cooldown_spec.rb` gets `name`/`version` on the shared spec helper, plus dedicated coverage for the version-grouped exclusion and stub-only fallback. `spec/install/cooldown_spec.rb` adds two end-to-end cases that pre-install `ripe_gem-2.0.0` and verify the in-cooldown copy is excluded while `--cooldown 0` continues to bypass the filter. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 88057ed commit 3920a09

5 files changed

Lines changed: 110 additions & 18 deletions

File tree

bundler/lib/bundler/remote_specification.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class RemoteSpecification
1212

1313
attr_reader :name, :version, :platform
1414
attr_writer :dependencies
15-
attr_accessor :source, :remote, :locked_platform
15+
attr_accessor :source, :remote, :locked_platform, :created_at
1616

1717
def initialize(name, version, platform, spec_fetcher)
1818
@name = name

bundler/lib/bundler/resolver.rb

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ def filter_matching_specs(specs, requirements)
422422
end
423423

424424
def filter_specs(specs, package)
425-
filter_cooldown(filter_remote_specs(filter_prereleases(specs, package), package))
425+
filter_remote_specs(filter_cooldown(filter_prereleases(specs, package)), package)
426426
end
427427

428428
def filter_prereleases(specs, package)
@@ -433,19 +433,19 @@ def filter_prereleases(specs, package)
433433

434434
def filter_cooldown(specs)
435435
return specs if specs.empty?
436-
excluded = cooldown_excluded_specs(specs)
437-
return specs if excluded.empty?
438-
specs - excluded
436+
excluded_versions = cooldown_excluded_versions(specs)
437+
return specs if excluded_versions.empty?
438+
specs.reject {|s| excluded_versions.include?([s.name, s.version]) }
439439
end
440440

441-
def cooldown_excluded_specs(specs)
442-
specs.select {|spec| cooldown_excluded?(spec) }
441+
def cooldown_excluded_versions(specs)
442+
specs.select {|spec| cooldown_excluded?(spec) }.map {|spec| [spec.name, spec.version] }.uniq
443443
end
444444

445445
def cooldown_hint(specs)
446-
excluded = cooldown_excluded_specs(specs)
447-
return nil if excluded.empty?
448-
"#{excluded.size} version#{"s" if excluded.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass"
446+
excluded_versions = cooldown_excluded_versions(specs)
447+
return nil if excluded_versions.empty?
448+
"#{excluded_versions.size} version#{"s" if excluded_versions.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass"
449449
end
450450

451451
def cooldown_excluded?(spec)

bundler/lib/bundler/source/rubygems.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ def specs
150150
# sources, and large_idx.merge! small_idx is way faster than
151151
# small_idx.merge! large_idx.
152152
index = @allow_remote ? remote_specs.dup : Index.new
153+
154+
# Snapshot per-version `created_at` from the remote info before installed
155+
# / cached specs overwrite the EndpointSpecification objects that carry
156+
# it. The cooldown filter consults `created_at` on every candidate, so
157+
# local stubs need the published date back-filled to participate.
158+
remote_created_at = collect_remote_created_at(index)
159+
153160
index.merge!(cached_specs) if @allow_cached
154161
index.merge!(installed_specs) if @allow_local
155162

@@ -163,6 +170,8 @@ def specs
163170
end
164171
end
165172

173+
backfill_created_at(index, remote_created_at) unless remote_created_at.empty?
174+
166175
index
167176
end
168177
end
@@ -470,6 +479,34 @@ def cache_path
470479

471480
private
472481

482+
def collect_remote_created_at(index)
483+
return {} unless @allow_remote
484+
485+
snapshot = {}
486+
index.each do |spec|
487+
next unless spec.respond_to?(:created_at) && spec.created_at
488+
snapshot[[spec.name, spec.version]] = spec.created_at
489+
end
490+
snapshot
491+
end
492+
493+
def backfill_created_at(index, snapshot)
494+
first_remote = remote_fetchers.keys.first
495+
index.each do |spec|
496+
next unless spec.respond_to?(:created_at=)
497+
next if spec.created_at
498+
remote_created_at = snapshot[[spec.name, spec.version]]
499+
next unless remote_created_at
500+
spec.created_at = remote_created_at
501+
# The cooldown filter consults `spec.remote.effective_cooldown`, so a
502+
# backfilled stub also needs a Source::Rubygems::Remote reference. Any
503+
# remote on the source carries the right `effective_cooldown` because
504+
# the setting is source-wide and `Bundler.settings[:cooldown]`
505+
# overrides per-source.
506+
spec.remote ||= first_remote if first_remote && spec.respond_to?(:remote=)
507+
end
508+
end
509+
473510
def lockfile_remotes
474511
@lockfile_remotes || credless_remotes
475512
end

spec/bundler/resolver/cooldown_spec.rb

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ def remote(cooldown:)
77
instance_double(Bundler::Source::Rubygems::Remote, effective_cooldown: cooldown)
88
end
99

10-
def spec(created_at:, remote:)
11-
Struct.new(:created_at, :remote).new(created_at, remote)
10+
def spec(created_at:, remote:, name: "myrack", version: "1.0.0")
11+
Struct.new(:name, :version, :created_at, :remote).new(name, Gem::Version.new(version), created_at, remote)
1212
end
1313

1414
describe "#filter_cooldown" do
@@ -18,8 +18,8 @@ def spec(created_at:, remote:)
1818
let(:r) { remote(cooldown: 7) }
1919

2020
it "rejects versions published within the window" do
21-
recent = spec(created_at: now - (2 * 86_400), remote: r)
22-
old = spec(created_at: now - (30 * 86_400), remote: r)
21+
recent = spec(version: "1.1.0", created_at: now - (2 * 86_400), remote: r)
22+
old = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r)
2323

2424
expect(resolver.send(:filter_cooldown, [recent, old])).to eq([old])
2525
end
@@ -32,14 +32,37 @@ def spec(created_at:, remote:)
3232

3333
it "leaves rolling-delay history intact" do
3434
# 7-day cooldown with frequent releases must still expose an older candidate.
35-
in_cooldown = spec(created_at: now - 86_400, remote: r)
36-
also_in_cooldown = spec(created_at: now - (3 * 86_400), remote: r)
37-
eligible = spec(created_at: now - (10 * 86_400), remote: r)
35+
in_cooldown = spec(version: "1.2.0", created_at: now - 86_400, remote: r)
36+
also_in_cooldown = spec(version: "1.1.0", created_at: now - (3 * 86_400), remote: r)
37+
eligible = spec(version: "1.0.0", created_at: now - (10 * 86_400), remote: r)
3838

3939
result = resolver.send(:filter_cooldown, [in_cooldown, also_in_cooldown, eligible])
4040

4141
expect(result).to eq([eligible])
4242
end
43+
44+
it "drops every spec sharing an excluded [name, version] tuple" do
45+
# The cooldown check is by version, not per-spec: a StubSpecification for an
46+
# in-cooldown release would otherwise slip through on local install paths.
47+
endpoint = spec(version: "2.0.0", created_at: now - 86_400, remote: r)
48+
local_stub = Struct.new(:name, :version).new("myrack", Gem::Version.new("2.0.0"))
49+
eligible = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r)
50+
51+
result = resolver.send(:filter_cooldown, [endpoint, local_stub, eligible])
52+
53+
expect(result).to eq([eligible])
54+
end
55+
56+
it "keeps stub-only versions that no endpoint marks as in cooldown" do
57+
# If no remote spec carries created_at for a version, cooldown cannot judge it;
58+
# the stub stays in.
59+
local_only = Struct.new(:name, :version).new("myrack", Gem::Version.new("2.0.0"))
60+
eligible = spec(version: "1.0.0", created_at: now - (30 * 86_400), remote: r)
61+
62+
result = resolver.send(:filter_cooldown, [local_only, eligible])
63+
64+
expect(result).to eq([local_only, eligible])
65+
end
4366
end
4467

4568
context "when created_at is missing (blank metadata)" do
@@ -111,9 +134,15 @@ def spec(created_at:, remote:)
111134
end
112135

113136
it "uses plural wording when multiple versions are excluded" do
114-
excluded = Array.new(3) { spec(created_at: now - 86_400, remote: r) }
137+
excluded = %w[1.0.0 1.1.0 1.2.0].map {|v| spec(version: v, created_at: now - 86_400, remote: r) }
115138

116139
expect(resolver.send(:cooldown_hint, excluded)).to match(/3 versions excluded/)
117140
end
141+
142+
it "counts each unique version once even when multiple spec instances share it" do
143+
duplicates = Array.new(3) { spec(created_at: now - 86_400, remote: r) }
144+
145+
expect(resolver.send(:cooldown_hint, duplicates)).to match(/1 version excluded/)
146+
end
118147
end
119148
end

spec/install/cooldown_spec.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,32 @@
215215
expect(out).to match(/ripe_gem.*in cooldown for \d+ more day/)
216216
end
217217

218+
it "excludes a locally-installed version that is still within the cooldown window" do
219+
system_gems "ripe_gem-2.0.0", gem_repo: gem_repo3
220+
221+
gemfile <<-G
222+
source "https://gem.repo3"
223+
gem "ripe_gem"
224+
G
225+
226+
bundle "install --cooldown 7", artifice: "compact_index_cooldown"
227+
228+
expect(the_bundle).to include_gems("ripe_gem 1.0.0")
229+
end
230+
231+
it "selects a locally-installed in-cooldown version when --cooldown 0 bypasses the filter" do
232+
system_gems "ripe_gem-2.0.0", gem_repo: gem_repo3
233+
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(the_bundle).to include_gems("ripe_gem 2.0.0")
242+
end
243+
218244
it "surfaces a cooldown hint when bundle update filters every candidate" do
219245
gemfile <<-G
220246
source "https://gem.repo3"

0 commit comments

Comments
 (0)