bundler 4.0.10 → 4.0.20

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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/bundler.gemspec +2 -2
  4. data/lib/bundler/build_metadata.rb +2 -2
  5. data/lib/bundler/cli/add.rb +3 -0
  6. data/lib/bundler/cli/check.rb +1 -3
  7. data/lib/bundler/cli/common.rb +17 -0
  8. data/lib/bundler/cli/config.rb +8 -3
  9. data/lib/bundler/cli/doctor/diagnose.rb +1 -1
  10. data/lib/bundler/cli/exec.rb +15 -1
  11. data/lib/bundler/cli/gem.rb +2 -3
  12. data/lib/bundler/cli/init.rb +4 -0
  13. data/lib/bundler/cli/install.rb +4 -0
  14. data/lib/bundler/cli/issue.rb +2 -2
  15. data/lib/bundler/cli/lock.rb +10 -2
  16. data/lib/bundler/cli/open.rb +12 -2
  17. data/lib/bundler/cli/outdated.rb +76 -6
  18. data/lib/bundler/cli/update.rb +3 -0
  19. data/lib/bundler/cli.rb +21 -15
  20. data/lib/bundler/compact_index_client/cache.rb +11 -0
  21. data/lib/bundler/compact_index_client/cache_file.rb +1 -1
  22. data/lib/bundler/compact_index_client/parser.rb +4 -1
  23. data/lib/bundler/definition.rb +64 -15
  24. data/lib/bundler/dsl.rb +11 -4
  25. data/lib/bundler/endpoint_specification.rb +30 -2
  26. data/lib/bundler/fetcher/base.rb +3 -0
  27. data/lib/bundler/fetcher/compact_index.rb +13 -0
  28. data/lib/bundler/fetcher/gem_remote_fetcher.rb +2 -0
  29. data/lib/bundler/fetcher.rb +12 -2
  30. data/lib/bundler/gem_helper.rb +2 -2
  31. data/lib/bundler/installer/parallel_installer.rb +68 -23
  32. data/lib/bundler/installer.rb +18 -1
  33. data/lib/bundler/lockfile_generator.rb +55 -1
  34. data/lib/bundler/lockfile_parser.rb +23 -1
  35. data/lib/bundler/man/bundle-add.1 +6 -3
  36. data/lib/bundler/man/bundle-add.1.ronn +7 -2
  37. data/lib/bundler/man/bundle-binstubs.1 +1 -1
  38. data/lib/bundler/man/bundle-cache.1 +4 -1
  39. data/lib/bundler/man/bundle-cache.1.ronn +6 -0
  40. data/lib/bundler/man/bundle-check.1 +1 -1
  41. data/lib/bundler/man/bundle-clean.1 +1 -1
  42. data/lib/bundler/man/bundle-config.1 +121 -157
  43. data/lib/bundler/man/bundle-config.1.ronn +38 -6
  44. data/lib/bundler/man/bundle-console.1 +3 -3
  45. data/lib/bundler/man/bundle-console.1.ronn +2 -2
  46. data/lib/bundler/man/bundle-doctor.1 +1 -1
  47. data/lib/bundler/man/bundle-env.1 +1 -1
  48. data/lib/bundler/man/bundle-exec.1 +1 -1
  49. data/lib/bundler/man/bundle-fund.1 +1 -1
  50. data/lib/bundler/man/bundle-gem.1 +1 -1
  51. data/lib/bundler/man/bundle-help.1 +1 -1
  52. data/lib/bundler/man/bundle-info.1 +1 -1
  53. data/lib/bundler/man/bundle-init.1 +2 -2
  54. data/lib/bundler/man/bundle-init.1.ronn +1 -1
  55. data/lib/bundler/man/bundle-install.1 +5 -2
  56. data/lib/bundler/man/bundle-install.1.ronn +9 -1
  57. data/lib/bundler/man/bundle-issue.1 +1 -1
  58. data/lib/bundler/man/bundle-licenses.1 +1 -1
  59. data/lib/bundler/man/bundle-list.1 +1 -1
  60. data/lib/bundler/man/bundle-lock.1 +5 -2
  61. data/lib/bundler/man/bundle-lock.1.ronn +6 -0
  62. data/lib/bundler/man/bundle-open.1 +1 -1
  63. data/lib/bundler/man/bundle-outdated.1 +17 -14
  64. data/lib/bundler/man/bundle-outdated.1.ronn +25 -12
  65. data/lib/bundler/man/bundle-platform.1 +5 -1
  66. data/lib/bundler/man/bundle-platform.1.ronn +4 -0
  67. data/lib/bundler/man/bundle-plugin.1 +2 -2
  68. data/lib/bundler/man/bundle-plugin.1.ronn +1 -1
  69. data/lib/bundler/man/bundle-pristine.1 +1 -1
  70. data/lib/bundler/man/bundle-remove.1 +1 -1
  71. data/lib/bundler/man/bundle-show.1 +1 -1
  72. data/lib/bundler/man/bundle-update.1 +5 -2
  73. data/lib/bundler/man/bundle-update.1.ronn +8 -0
  74. data/lib/bundler/man/bundle-version.1 +1 -1
  75. data/lib/bundler/man/bundle.1 +23 -2
  76. data/lib/bundler/man/bundle.1.ronn +22 -1
  77. data/lib/bundler/man/gemfile.5 +14 -3
  78. data/lib/bundler/man/gemfile.5.ronn +23 -2
  79. data/lib/bundler/mirror.rb +7 -7
  80. data/lib/bundler/plugin/unloaded_source.rb +25 -0
  81. data/lib/bundler/plugin.rb +12 -6
  82. data/lib/bundler/remote_specification.rb +1 -1
  83. data/lib/bundler/resolver/base.rb +9 -0
  84. data/lib/bundler/resolver.rb +116 -2
  85. data/lib/bundler/rubygems_ext.rb +22 -0
  86. data/lib/bundler/rubygems_gem_installer.rb +35 -2
  87. data/lib/bundler/runtime.rb +11 -11
  88. data/lib/bundler/self_manager.rb +6 -1
  89. data/lib/bundler/settings.rb +1 -0
  90. data/lib/bundler/shared_helpers.rb +21 -1
  91. data/lib/bundler/source/git/git_proxy.rb +83 -10
  92. data/lib/bundler/source/git.rb +4 -4
  93. data/lib/bundler/source/metadata.rb +4 -0
  94. data/lib/bundler/source/path.rb +4 -3
  95. data/lib/bundler/source/rubygems/remote.rb +12 -2
  96. data/lib/bundler/source/rubygems.rb +81 -13
  97. data/lib/bundler/source_list.rb +44 -3
  98. data/lib/bundler/spec_set.rb +2 -2
  99. data/lib/bundler/stub_specification.rb +3 -1
  100. data/lib/bundler/templates/newgem/newgem.gemspec.tt +8 -2
  101. data/lib/bundler/vendored_tsort.rb +19 -2
  102. data/lib/bundler/version.rb +1 -1
  103. data/lib/bundler.rb +26 -6
  104. metadata +5 -6
  105. data/lib/bundler/vendor/tsort/LICENSE.txt +0 -22
  106. data/lib/bundler/vendor/tsort/lib/tsort.rb +0 -455
@@ -1,6 +1,6 @@
1
1
  .\" generated with Ronn-NG/v0.10.1
2
2
  .\" http://github.com/apjanke/ronn-ng/tree/0.10.1
3
- .TH "BUNDLE" "1" "March 2026" ""
3
+ .TH "BUNDLE" "1" "April 2026" ""
4
4
  .SH "NAME"
5
5
  \fBbundle\fR \- Ruby Dependency Management
6
6
  .SH "SYNOPSIS"
@@ -8,7 +8,7 @@
8
8
  .SH "DESCRIPTION"
9
9
  Bundler manages an \fBapplication's dependencies\fR through its entire life across many machines systematically and repeatably\.
10
10
  .P
11
- See the bundler website \fIhttps://bundler\.io\fR for information on getting started, and Gemfile(5) for more information on the \fBGemfile\fR format\.
11
+ See the RubyGems guides \fIhttps://guides\.rubygems\.org/\fR for information on getting started, and Gemfile(5) for more information on the \fBGemfile\fR format\.
12
12
  .SH "OPTIONS"
13
13
  .TP
14
14
  \fB\-\-no\-color\fR
@@ -54,6 +54,18 @@ Determine whether the requirements for your application are installed and availa
54
54
  \fBbundle show(1)\fR \fIbundle\-show\.1\.html\fR
55
55
  Show the source location of a particular gem in the bundle
56
56
  .TP
57
+ \fBbundle info(1)\fR \fIbundle\-info\.1\.html\fR
58
+ Show information for the given gem in your bundle
59
+ .TP
60
+ \fBbundle list(1)\fR \fIbundle\-list\.1\.html\fR
61
+ List all the gems in the bundle
62
+ .TP
63
+ \fBbundle licenses(1)\fR \fIbundle\-licenses\.1\.html\fR
64
+ Print the license of all gems in the bundle
65
+ .TP
66
+ \fBbundle fund(1)\fR \fIbundle\-fund\.1\.html\fR
67
+ Lists information about gems seeking funding assistance
68
+ .TP
57
69
  \fBbundle outdated(1)\fR \fIbundle\-outdated\.1\.html\fR
58
70
  Show all of the outdated gems in the current bundle
59
71
  .TP
@@ -78,9 +90,18 @@ Display platform compatibility information
78
90
  \fBbundle clean(1)\fR \fIbundle\-clean\.1\.html\fR
79
91
  Clean up unused gems in your Bundler directory
80
92
  .TP
93
+ \fBbundle pristine(1)\fR \fIbundle\-pristine\.1\.html\fR
94
+ Restores installed gems to their pristine condition
95
+ .TP
81
96
  \fBbundle doctor(1)\fR \fIbundle\-doctor\.1\.html\fR
82
97
  Display warnings about common problems
83
98
  .TP
99
+ \fBbundle env(1)\fR \fIbundle\-env\.1\.html\fR
100
+ Print information about the environment Bundler is running under
101
+ .TP
102
+ \fBbundle issue(1)\fR \fIbundle\-issue\.1\.html\fR
103
+ Get help reporting Bundler issues
104
+ .TP
84
105
  \fBbundle remove(1)\fR \fIbundle\-remove\.1\.html\fR
85
106
  Removes gems from the Gemfile
86
107
  .TP
@@ -10,7 +10,7 @@ bundle(1) -- Ruby Dependency Management
10
10
  Bundler manages an `application's dependencies` through its entire life
11
11
  across many machines systematically and repeatably.
12
12
 
13
- See [the bundler website](https://bundler.io) for information on getting
13
+ See [the RubyGems guides](https://guides.rubygems.org/) for information on getting
14
14
  started, and Gemfile(5) for more information on the `Gemfile` format.
15
15
 
16
16
  ## OPTIONS
@@ -64,6 +64,18 @@ We divide `bundle` subcommands into primary commands and utilities:
64
64
  * [`bundle show(1)`](bundle-show.1.html):
65
65
  Show the source location of a particular gem in the bundle
66
66
 
67
+ * [`bundle info(1)`](bundle-info.1.html):
68
+ Show information for the given gem in your bundle
69
+
70
+ * [`bundle list(1)`](bundle-list.1.html):
71
+ List all the gems in the bundle
72
+
73
+ * [`bundle licenses(1)`](bundle-licenses.1.html):
74
+ Print the license of all gems in the bundle
75
+
76
+ * [`bundle fund(1)`](bundle-fund.1.html):
77
+ Lists information about gems seeking funding assistance
78
+
67
79
  * [`bundle outdated(1)`](bundle-outdated.1.html):
68
80
  Show all of the outdated gems in the current bundle
69
81
 
@@ -88,9 +100,18 @@ We divide `bundle` subcommands into primary commands and utilities:
88
100
  * [`bundle clean(1)`](bundle-clean.1.html):
89
101
  Clean up unused gems in your Bundler directory
90
102
 
103
+ * [`bundle pristine(1)`](bundle-pristine.1.html):
104
+ Restores installed gems to their pristine condition
105
+
91
106
  * [`bundle doctor(1)`](bundle-doctor.1.html):
92
107
  Display warnings about common problems
93
108
 
109
+ * [`bundle env(1)`](bundle-env.1.html):
110
+ Print information about the environment Bundler is running under
111
+
112
+ * [`bundle issue(1)`](bundle-issue.1.html):
113
+ Get help reporting Bundler issues
114
+
94
115
  * [`bundle remove(1)`](bundle-remove.1.html):
95
116
  Removes gems from the Gemfile
96
117
 
@@ -1,6 +1,6 @@
1
1
  .\" generated with Ronn-NG/v0.10.1
2
2
  .\" http://github.com/apjanke/ronn-ng/tree/0.10.1
3
- .TH "GEMFILE" "5" "March 2026" ""
3
+ .TH "GEMFILE" "5" "April 2026" ""
4
4
  .SH "NAME"
5
5
  \fBGemfile\fR \- A format for describing gem dependencies for Ruby programs
6
6
  .SH "SYNOPSIS"
@@ -107,6 +107,8 @@ gem "nokogiri", ">= 1\.4\.2"
107
107
  gem "RedCloth", ">= 4\.1\.0", "< 4\.2\.0"
108
108
  .fi
109
109
  .IP "" 0
110
+ .P
111
+ Most version specifiers, like \fB>= 1\.0\fR, are self\-explanatory\. The pessimistic specifier \fB~>\fR constrains both a lower and an upper bound: \fB~> 2\.0\.3\fR is identical to \fB>= 2\.0\.3\fR and \fB< 2\.1\fR, and \fB~> 2\.1\fR is identical to \fB>= 2\.1\fR and \fB< 3\.0\fR\. A pessimistic specifier with a prerelease component, like \fB~> 2\.2\.beta\fR, will match prerelease versions like \fB2\.2\.beta\.12\fR\. \fB~> 0\fR is identical to \fB>= 0\.0\fR and \fB< 1\.0\fR\.
110
112
  .SS "REQUIRE AS"
111
113
  Each \fIgem\fR \fBMAY\fR specify files that should be used when autorequiring via \fBBundler\.require\fR\. You may pass an array with multiple files or \fBtrue\fR if the file you want \fBrequired\fR has the same name as \fIgem\fR or \fBfalse\fR to prevent any file from being autorequired\.
112
114
  .IP "" 4
@@ -164,7 +166,7 @@ bundle config set \-\-local without development test
164
166
  .P
165
167
  Also, calling \fBBundler\.setup\fR with no parameters, or calling \fBrequire "bundler/setup"\fR will setup all groups except for the ones you excluded via \fB\-\-without\fR (since they are not available)\.
166
168
  .P
167
- Note that on \fBbundle install\fR, bundler downloads and evaluates all gems, in order to create a single canonical list of all of the required gems and their dependencies\. This means that you cannot list different versions of the same gems in different groups\. For more details, see Understanding Bundler \fIhttps://bundler\.io/rationale\.html\fR\.
169
+ Note that on \fBbundle install\fR, bundler downloads and evaluates all gems, in order to create a single canonical list of all of the required gems and their dependencies\. This means that you cannot list different versions of the same gems in different groups\. For more details, see Dependency management \fIhttps://guides\.rubygems\.org/dependency_management/\fR\.
168
170
  .SS "PLATFORMS"
169
171
  If a gem should only be used in a particular platform or set of platforms, you can specify them\. Platforms are essentially identical to groups, except that you do not need to use the \fB\-\-without\fR install\-time flag to exclude groups of gems for other platforms\.
170
172
  .P
@@ -390,7 +392,7 @@ gem "rails", git: "https://rails@bitbucket\.org/rails/rails\.git"
390
392
  .P
391
393
  Since the \fBbitbucket\fR method is a specialization of \fBgit_source\fR, it accepts a \fB:branch\fR named argument\.
392
394
  .SS "PATH"
393
- You can specify that a gem is located in a particular location on the file system\. Relative paths are resolved relative to the directory containing the \fBGemfile\fR\.
395
+ You can specify that a gem is located in a particular location on the file system\. Relative paths are resolved relative to the directory containing the \fBGemfile\fR\. The path must point to a directory containing the gem's unpacked source code, not to a packaged \fB\.gem\fR file\.
394
396
  .P
395
397
  Similar to the semantics of the \fB:git\fR option, the \fB:path\fR option requires that the directory in question either contains a \fB\.gemspec\fR for the gem, or that you specify an explicit version that bundler should use\.
396
398
  .P
@@ -460,6 +462,15 @@ The \fBgemspec\fR method adds any runtime dependencies as gem requirements in th
460
462
  The \fBgemspec\fR method supports optional \fB:path\fR, \fB:glob\fR, \fB:name\fR, and \fB:development_group\fR options, which control where bundler looks for the \fB\.gemspec\fR, the glob it uses to look for the gemspec (defaults to: \fB{,*,*/*}\.gemspec\fR), what named \fB\.gemspec\fR it uses (if more than one is present), and which group development dependencies are included in\.
461
463
  .P
462
464
  When a \fBgemspec\fR dependency encounters version conflicts during resolution, the local version under development will always be selected \-\- even if there are remote versions that better match other requirements for the \fBgemspec\fR gem\.
465
+ .SH "EVAL_GEMFILE"
466
+ A Gemfile \fBMAY\fR be split across multiple files\. The \fBeval_gemfile\fR method reads the contents of another file as if they appeared in the current Gemfile\. A relative path is resolved against the directory of the file containing the call\.
467
+ .IP "" 4
468
+ .nf
469
+ eval_gemfile "another\.gemfile"
470
+ .fi
471
+ .IP "" 0
472
+ .P
473
+ This can be useful to run tests against multiple combinations of dependencies, or to load per\-developer gems from an untracked gemfile\.
463
474
  .SH "SOURCE PRIORITY"
464
475
  When attempting to locate a gem to satisfy a gem requirement, bundler uses the following priority order:
465
476
  .IP "1." 4
@@ -137,6 +137,13 @@ Each _gem_ `MAY` have one or more version specifiers.
137
137
  gem "nokogiri", ">= 1.4.2"
138
138
  gem "RedCloth", ">= 4.1.0", "< 4.2.0"
139
139
 
140
+ Most version specifiers, like `>= 1.0`, are self-explanatory. The pessimistic
141
+ specifier `~>` constrains both a lower and an upper bound: `~> 2.0.3` is
142
+ identical to `>= 2.0.3` and `< 2.1`, and `~> 2.1` is identical to `>= 2.1` and
143
+ `< 3.0`. A pessimistic specifier with a prerelease component, like
144
+ `~> 2.2.beta`, will match prerelease versions like `2.2.beta.12`. `~> 0` is
145
+ identical to `>= 0.0` and `< 1.0`.
146
+
140
147
  ### REQUIRE AS
141
148
 
142
149
  Each _gem_ `MAY` specify files that should be used when autorequiring via
@@ -193,7 +200,7 @@ are not available).
193
200
  Note that on `bundle install`, bundler downloads and evaluates all gems, in order to
194
201
  create a single canonical list of all of the required gems and their dependencies.
195
202
  This means that you cannot list different versions of the same gems in different
196
- groups. For more details, see [Understanding Bundler](https://bundler.io/rationale.html).
203
+ groups. For more details, see [Dependency management](https://guides.rubygems.org/dependency_management/).
197
204
 
198
205
  ### PLATFORMS
199
206
 
@@ -444,7 +451,9 @@ Since the `bitbucket` method is a specialization of `git_source`, it accepts a `
444
451
 
445
452
  You can specify that a gem is located in a particular location
446
453
  on the file system. Relative paths are resolved relative to the
447
- directory containing the `Gemfile`.
454
+ directory containing the `Gemfile`. The path must point to a
455
+ directory containing the gem's unpacked source code, not to a
456
+ packaged `.gem` file.
448
457
 
449
458
  Similar to the semantics of the `:git` option, the `:path`
450
459
  option requires that the directory in question either contains
@@ -541,6 +550,18 @@ When a `gemspec` dependency encounters version conflicts during resolution, the
541
550
  local version under development will always be selected -- even if there are
542
551
  remote versions that better match other requirements for the `gemspec` gem.
543
552
 
553
+ ## EVAL_GEMFILE
554
+
555
+ A Gemfile `MAY` be split across multiple files. The `eval_gemfile` method reads
556
+ the contents of another file as if they appeared in the current Gemfile. A
557
+ relative path is resolved against the directory of the file containing the
558
+ call.
559
+
560
+ eval_gemfile "another.gemfile"
561
+
562
+ This can be useful to run tests against multiple combinations of dependencies,
563
+ or to load per-developer gems from an untracked gemfile.
564
+
544
565
  ## SOURCE PRIORITY
545
566
 
546
567
  When attempting to locate a gem to satisfy a gem requirement,
@@ -160,18 +160,18 @@ module Bundler
160
160
 
161
161
  def wait_for_writtable_socket(socket, address, timeout)
162
162
  if IO.select(nil, [socket], nil, timeout)
163
- probe_writtable_socket(socket, address)
163
+ probe_writtable_socket(socket)
164
164
  else # TCP Handshake timed out, or there is something dropping packets
165
165
  false
166
166
  end
167
167
  end
168
168
 
169
- def probe_writtable_socket(socket, address)
170
- socket.connect_nonblock(address)
171
- rescue Errno::EISCONN
172
- true
173
- rescue StandardError # Connection failed
174
- false
169
+ def probe_writtable_socket(socket)
170
+ # Check the pending error on the socket rather than calling
171
+ # +connect_nonblock+ a second time. On BSD-based systems such as macOS a
172
+ # second connect returns +EISCONN+ even when the asynchronous connect
173
+ # failed, which would make a down mirror look reachable.
174
+ socket.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int == 0
175
175
  end
176
176
  end
177
177
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Plugin
5
+ # Stands in for a source handled by a plugin that is not loaded yet, so
6
+ # that the lockfile can still be parsed during the plugin install pass,
7
+ # and by external tools reading a lockfile without the plugin installed.
8
+ class UnloadedSource
9
+ include API::Source
10
+
11
+ # Unlike real plugin sources, where the handling class encodes the
12
+ # source type, all unloaded sources share this class, so the type must
13
+ # be compared explicitly.
14
+ def ==(other)
15
+ super && options["type"] == other.options["type"]
16
+ end
17
+
18
+ alias_method :eql?, :==
19
+
20
+ def hash
21
+ [super, options["type"]].hash
22
+ end
23
+ end
24
+ end
25
+ end
@@ -4,11 +4,12 @@ require_relative "plugin/api"
4
4
 
5
5
  module Bundler
6
6
  module Plugin
7
- autoload :DSL, File.expand_path("plugin/dsl", __dir__)
8
- autoload :Events, File.expand_path("plugin/events", __dir__)
9
- autoload :Index, File.expand_path("plugin/index", __dir__)
10
- autoload :Installer, File.expand_path("plugin/installer", __dir__)
11
- autoload :SourceList, File.expand_path("plugin/source_list", __dir__)
7
+ autoload :DSL, File.expand_path("plugin/dsl", __dir__)
8
+ autoload :Events, File.expand_path("plugin/events", __dir__)
9
+ autoload :Index, File.expand_path("plugin/index", __dir__)
10
+ autoload :Installer, File.expand_path("plugin/installer", __dir__)
11
+ autoload :SourceList, File.expand_path("plugin/source_list", __dir__)
12
+ autoload :UnloadedSource, File.expand_path("plugin/unloaded_source", __dir__)
12
13
 
13
14
  class MalformattedPlugin < PluginError; end
14
15
  class UndefinedCommandError < PluginError; end
@@ -199,9 +200,14 @@ module Bundler
199
200
  # @return [API::Source] the instance of the class that handles the source
200
201
  # type passed in locked_opts
201
202
  def from_lock(locked_opts)
203
+ opts = locked_opts.merge("uri" => locked_opts["remote"])
204
+ # use an inert placeholder when the plugin handling this source is not
205
+ # installed, so that the lockfile can still be parsed
206
+ return UnloadedSource.new(opts) unless source?(locked_opts["type"])
207
+
202
208
  src = source(locked_opts["type"])
203
209
 
204
- src.new(locked_opts.merge("uri" => locked_opts["remote"]))
210
+ src.new(opts)
205
211
  end
206
212
 
207
213
  # To be called via the API to register a hooks and corresponding block that
@@ -12,7 +12,7 @@ module Bundler
12
12
 
13
13
  attr_reader :name, :version, :platform
14
14
  attr_writer :dependencies
15
- attr_accessor :source, :remote, :locked_platform
15
+ attr_accessor :source, :remote, :locked_platform, :created_at
16
16
 
17
17
  def initialize(name, version, platform, spec_fetcher)
18
18
  @name = name
@@ -8,6 +8,7 @@ module Bundler
8
8
  attr_reader :packages, :requirements, :source_requirements, :locked_specs
9
9
 
10
10
  def initialize(source_requirements, dependencies, base, platforms, options)
11
+ @explicit_unlocks = options.delete(:explicit_unlocks) || []
11
12
  @source_requirements = source_requirements
12
13
  @locked_specs = options[:locked_specs]
13
14
 
@@ -43,6 +44,14 @@ module Bundler
43
44
  @packages[name]
44
45
  end
45
46
 
47
+ # Gems the user named on a `bundle update GEM` / `bundle lock --update GEM`
48
+ # command line. These are the only ones meant to move off their locked
49
+ # version, so cooldown keeps applying to them while every other locked gem
50
+ # stays exempt.
51
+ def explicitly_unlocked?(name)
52
+ @explicit_unlocks.include?(name)
53
+ end
54
+
46
55
  def base_requirements
47
56
  @base_requirements ||= build_base_requirements
48
57
  end
@@ -14,11 +14,15 @@ module Bundler
14
14
  require_relative "resolver/root"
15
15
  require_relative "resolver/strategy"
16
16
 
17
+ attr_reader :cooldown_skipped
18
+
17
19
  def initialize(base, gem_version_promoter, most_specific_locked_platform = nil)
18
20
  @source_requirements = base.source_requirements
19
21
  @base = base
20
22
  @gem_version_promoter = gem_version_promoter
21
23
  @most_specific_locked_platform = most_specific_locked_platform
24
+ @cooldown_skipped = []
25
+ @cooldown_skipped_specs = {}
22
26
  end
23
27
 
24
28
  def start
@@ -36,6 +40,8 @@ module Bundler
36
40
  root = Resolver::Root.new(name_for_explicit_dependency_source)
37
41
  root_version = Resolver::Candidate.new(0)
38
42
 
43
+ @cooldown_skipped_specs = {}
44
+
39
45
  @all_specs = Hash.new do |specs, name|
40
46
  source = source_for(name)
41
47
  matches = source.specs.search(name)
@@ -80,7 +86,9 @@ module Bundler
80
86
  solver = PubGrub::VersionSolver.new(source: self, root: root, strategy: Strategy.new(self), logger: logger)
81
87
  result = solver.solve
82
88
  resolved_specs = result.flat_map {|package, version| version.to_specs(package, @most_specific_locked_platform) }
83
- SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs)
89
+ spec_set = SpecSet.new(resolved_specs).specs_with_additional_variants_from(@base.locked_specs)
90
+ @cooldown_skipped = cooldown_skipped_summary(spec_set)
91
+ spec_set
84
92
  rescue PubGrub::SolveFailure => e
85
93
  incompatibility = e.incompatibility
86
94
 
@@ -184,6 +192,9 @@ module Bundler
184
192
 
185
193
  platforms_explanation = specs_matching_other_platforms.any? ? " for any resolution platforms (#{package.platforms.join(", ")})" : ""
186
194
  custom_explanation = "#{constraint} could not be found in #{repository_for(package)}#{platforms_explanation}"
195
+ if hint = cooldown_hint(specs_matching_other_platforms)
196
+ custom_explanation += " (#{hint})"
197
+ end
187
198
 
188
199
  label = "#{name} (#{constraint_string})"
189
200
  extended_explanation = other_specs_matching_message(specs_matching_other_platforms, label) if specs_matching_other_platforms.any?
@@ -353,6 +364,10 @@ module Bundler
353
364
  message << "\n#{other_specs_matching_message(specs, matching_part)}"
354
365
  end
355
366
 
367
+ if hint = cooldown_hint(specs_matching_requirement)
368
+ message << "\n\n#{hint}."
369
+ end
370
+
356
371
  if specs_matching_requirement.any? && (hint = platform_mismatch_hint)
357
372
  message << "\n\n#{hint}"
358
373
  end
@@ -396,7 +411,7 @@ module Bundler
396
411
  end
397
412
 
398
413
  def filter_specs(specs, package)
399
- filter_remote_specs(filter_prereleases(specs, package), package)
414
+ filter_remote_specs(filter_cooldown(filter_prereleases(specs, package)), package)
400
415
  end
401
416
 
402
417
  def filter_prereleases(specs, package)
@@ -405,6 +420,105 @@ module Bundler
405
420
  specs.reject {|s| s.version.prerelease? }
406
421
  end
407
422
 
423
+ def filter_cooldown(specs)
424
+ return specs if specs.empty?
425
+ excluded_versions = cooldown_excluded_versions(specs)
426
+ return specs if excluded_versions.empty?
427
+ specs.reject {|s| excluded_versions.include?([s.name, s.version]) }
428
+ end
429
+
430
+ def cooldown_excluded_versions(specs)
431
+ excluded = {}
432
+ specs.each do |spec|
433
+ next unless cooldown_excluded?(spec)
434
+ excluded[[spec.name, spec.version]] = true
435
+ (@cooldown_skipped_specs ||= {})[[spec.name, spec.version]] ||= spec
436
+ end
437
+ excluded
438
+ end
439
+
440
+ def cooldown_hint(specs)
441
+ excluded_versions = cooldown_excluded_versions(specs)
442
+ return nil if excluded_versions.empty?
443
+ "#{excluded_versions.size} version#{"s" if excluded_versions.size > 1} excluded by the cooldown setting; pass `--cooldown 0` to bypass"
444
+ end
445
+
446
+ def cooldown_excluded?(spec)
447
+ return false unless spec.respond_to?(:created_at) && spec.created_at
448
+ return false unless spec.respond_to?(:remote) && spec.remote
449
+ return false if locked_by_lockfile?(spec)
450
+ days = spec.remote.effective_cooldown
451
+ return false if days.nil? || days <= 0
452
+ (cooldown_now - spec.created_at) < (days * 86_400)
453
+ end
454
+
455
+ # A version already written to the lockfile has been adopted, and cooldown
456
+ # only governs the adoption of *new* versions, so it must never retract one
457
+ # the lockfile already pins. Keying this off the locked specs rather than the
458
+ # prevent-downgrade floor matters because that floor is absent on resolutions
459
+ # that re-pick a gem from scratch: the auxiliary full update run to compute
460
+ # `--update` targets, and the from-scratch retries after a conflict unlocks a
461
+ # gem. In those passes the locked version is the only candidate, so filtering
462
+ # it out makes an unrelated operation impossible whenever every published
463
+ # version matching the requirement sits inside the cooldown window.
464
+ #
465
+ # Gems named on a `bundle update GEM` command are the exception: the user
466
+ # asked to move them, so they stay subject to cooldown and a locked-but-fresh
467
+ # release is pushed back to an older one (or fails loudly when none exists).
468
+ def locked_by_lockfile?(spec)
469
+ return false unless defined?(@base) && @base
470
+ return false if @base.explicitly_unlocked?(spec.name)
471
+ @base.locked_specs[spec.name].any? {|locked| locked.version == spec.version }
472
+ end
473
+
474
+ def cooldown_now
475
+ @cooldown_now ||= Time.now
476
+ end
477
+
478
+ # Reports, per gem, the newest version that cooldown kept out of a
479
+ # successful resolution. A skipped version is only worth reporting when it
480
+ # is newer than the resolved version and satisfies every requirement the
481
+ # final resolution places on that gem, so we don't claim a version the
482
+ # resolver could never have picked anyway.
483
+ def cooldown_skipped_summary(spec_set)
484
+ return [] if @cooldown_skipped_specs.empty?
485
+
486
+ requirements = Hash.new {|h, name| h[name] = [] }
487
+ @requirements.each {|dep| requirements[dep.name] << dep.requirement }
488
+ spec_set.each do |spec|
489
+ spec.dependencies.each {|dep| requirements[dep.name] << dep.requirement }
490
+ end
491
+
492
+ resolved_versions = {}
493
+ spec_set.each do |spec|
494
+ version = resolved_versions[spec.name]
495
+ resolved_versions[spec.name] = spec.version if version.nil? || spec.version > version
496
+ end
497
+
498
+ newest_skipped = {}
499
+ @cooldown_skipped_specs.each do |(name, version), spec|
500
+ resolved = resolved_versions[name]
501
+ next unless resolved && version > resolved
502
+ next unless requirements[name].all? {|req| req.satisfied_by?(version) }
503
+ newest = newest_skipped[name]
504
+ newest_skipped[name] = spec if newest.nil? || version > newest.version
505
+ end
506
+
507
+ newest_skipped.values.sort_by(&:name).map do |spec|
508
+ {
509
+ name: spec.name,
510
+ version: spec.version,
511
+ resolved: resolved_versions[spec.name],
512
+ available_in_days: remaining_cooldown_days(spec),
513
+ }
514
+ end
515
+ end
516
+
517
+ def remaining_cooldown_days(spec)
518
+ remaining = (spec.remote.effective_cooldown * 86_400) - (cooldown_now - spec.created_at)
519
+ [(remaining / 86_400.0).ceil, 1].max
520
+ end
521
+
408
522
  def filter_remote_specs(specs, package)
409
523
  if package.prefer_local?
410
524
  local_specs = specs.select {|s| s.is_a?(StubSpecification) }
@@ -465,6 +465,28 @@ module Gem
465
465
  Resolver::APISet::GemParser.prepend(UnfreezeCompactIndexParsedResponse)
466
466
  end
467
467
 
468
+ # RubyGems before 4.0.13 split compact index dependency/requirement entries
469
+ # on every colon, which mangles metadata values that contain colons such as
470
+ # the `created_at` timestamps the cooldown feature relies on. Split only on
471
+ # the first colon so those values survive on older RubyGems.
472
+ #
473
+ # The module is defined unconditionally so it stays testable on any RubyGems,
474
+ # but only prepended when the host RubyGems still has the buggy behavior.
475
+ module SplitCompactIndexEntryOnFirstColon
476
+ private
477
+
478
+ def parse_dependency(string)
479
+ dependency = string.split(":", 2)
480
+ dependency[-1] = dependency[-1].split("&") if dependency.size > 1
481
+ dependency[0] = -dependency[0]
482
+ dependency
483
+ end
484
+ end
485
+
486
+ unless Gem.rubygems_version >= Gem::Version.new("4.0.13")
487
+ Resolver::APISet::GemParser.prepend(SplitCompactIndexEntryOnFirstColon)
488
+ end
489
+
468
490
  if Gem.rubygems_version < Gem::Version.new("3.6.0")
469
491
  class Package; end
470
492
  require "rubygems/package/tar_reader"
@@ -4,6 +4,11 @@ require "rubygems/installer"
4
4
 
5
5
  module Bundler
6
6
  class RubyGemsGemInstaller < Gem::Installer
7
+ # Cap how many jobserver slots a single gem's `make` may grab so that one
8
+ # gem with many recipes doesn't starve the others sharing the pool. Beyond
9
+ # a handful of jobs the extra parallelism rarely pays off in practice.
10
+ MAX_JOBS_PER_GEM = 3
11
+
7
12
  def check_executable_overwrite(filename)
8
13
  # Bundler needs to install gems regardless of binstub overwriting
9
14
  end
@@ -20,7 +25,7 @@ module Bundler
20
25
  strict_rm_rf spec.extension_dir
21
26
 
22
27
  SharedHelpers.filesystem_access(gem_dir, :create) do
23
- FileUtils.mkdir_p gem_dir, mode: 0o755
28
+ FileUtils.mkdir_p gem_dir
24
29
  end
25
30
 
26
31
  SharedHelpers.filesystem_access(gem_dir, :write) do
@@ -101,10 +106,18 @@ module Bundler
101
106
  end
102
107
 
103
108
  def build_jobs
104
- Bundler.settings[:jobs] || super
109
+ @jobserver_read_io&.read_nonblock(MAX_JOBS_PER_GEM, @jobserver_tokens)
110
+ acquired_jobs = @jobserver_tokens.empty? ? nil : @jobserver_tokens.size
111
+
112
+ acquired_jobs || Bundler.settings[:jobs] || super
113
+ rescue IO::WaitReadable, EOFError
114
+ 1
105
115
  end
106
116
 
107
117
  def build_extensions
118
+ @jobserver_tokens = +""
119
+ @jobserver_read_io, @jobserver_write_io = connect_to_jobserver
120
+
108
121
  extension_cache_path = options[:bundler_extension_cache_path]
109
122
  extension_dir = spec.extension_dir
110
123
  unless extension_cache_path && extension_dir
@@ -128,6 +141,11 @@ module Bundler
128
141
  FileUtils.cp_r extension_dir, extension_cache_path
129
142
  end
130
143
  end
144
+ ensure
145
+ unless @jobserver_tokens.empty?
146
+ @jobserver_write_io.write(@jobserver_tokens)
147
+ @jobserver_write_io.flush
148
+ end
131
149
  end
132
150
 
133
151
  def spec
@@ -144,6 +162,21 @@ module Bundler
144
162
 
145
163
  private
146
164
 
165
+ def connect_to_jobserver
166
+ return unless ENV["MAKEFLAGS"]
167
+ # We append our own --jobserver-auth, so read the last one. Otherwise a
168
+ # parent jobserver's descriptors (e.g. `bundle install` run under
169
+ # `make -j`) would be picked up instead of the pool ParallelInstaller created.
170
+ read_fd, write_fd = ENV["MAKEFLAGS"].scan(/--jobserver-auth=(\d+),(\d+)/).last
171
+
172
+ return unless read_fd && write_fd
173
+
174
+ # Pass explicit modes. On POSIX, IO.new detects the descriptor's access
175
+ # mode, but on Windows it can't, so the write end would default to read
176
+ # mode and raise "IOError: not opened for writing" when releasing slots.
177
+ [IO.new(read_fd.to_i, "r", autoclose: false), IO.new(write_fd.to_i, "w", autoclose: false)]
178
+ end
179
+
147
180
  def prepare_extension_build(extension_dir)
148
181
  SharedHelpers.filesystem_access(extension_dir, :create) do
149
182
  FileUtils.mkdir_p extension_dir
@@ -141,7 +141,7 @@ module Bundler
141
141
  end
142
142
  end
143
143
 
144
- Dir[cache_path.join("*/.git")].each do |git_dir|
144
+ SharedHelpers.glob_files_in_dir("*/.git", cache_path.to_s).each do |git_dir|
145
145
  FileUtils.rm_rf(git_dir)
146
146
  FileUtils.touch(File.expand_path("../.bundlecache", git_dir))
147
147
  end
@@ -159,13 +159,13 @@ module Bundler
159
159
  end
160
160
 
161
161
  def clean(dry_run = false)
162
- gem_bins = Dir["#{Gem.dir}/bin/*"]
163
- git_dirs = Dir["#{Gem.dir}/bundler/gems/*"]
164
- git_cache_dirs = Dir["#{Gem.dir}/cache/bundler/git/*"]
165
- gem_dirs = Dir["#{Gem.dir}/gems/*"]
166
- gem_files = Dir["#{Gem.dir}/cache/*.gem"]
167
- gemspec_files = Dir["#{Gem.dir}/specifications/*.gemspec"]
168
- extension_dirs = Dir["#{Gem.dir}/extensions/*/*/*"] + Dir["#{Gem.dir}/bundler/gems/extensions/*/*/*"]
162
+ gem_bins = SharedHelpers.glob_files_in_dir("bin/*", Gem.dir)
163
+ git_dirs = SharedHelpers.glob_files_in_dir("bundler/gems/*", Gem.dir)
164
+ git_cache_dirs = SharedHelpers.glob_files_in_dir("cache/bundler/git/*", Gem.dir)
165
+ gem_dirs = SharedHelpers.glob_files_in_dir("gems/*", Gem.dir)
166
+ gem_files = SharedHelpers.glob_files_in_dir("cache/*.gem", Gem.dir)
167
+ gemspec_files = SharedHelpers.glob_files_in_dir("specifications/*.gemspec", Gem.dir)
168
+ extension_dirs = SharedHelpers.glob_files_in_dir("extensions/*/*/*", Gem.dir) + SharedHelpers.glob_files_in_dir("bundler/gems/extensions/*/*/*", Gem.dir)
169
169
  spec_gem_paths = []
170
170
  # need to keep git sources around
171
171
  spec_git_paths = @definition.spec_git_paths
@@ -232,7 +232,7 @@ module Bundler
232
232
  private
233
233
 
234
234
  def prune_gem_cache(resolve, cache_path)
235
- cached = Dir["#{cache_path}/*.gem"]
235
+ cached = SharedHelpers.glob_files_in_dir("*.gem", cache_path.to_s)
236
236
 
237
237
  cached = cached.delete_if do |path|
238
238
  spec = Bundler.rubygems.spec_from_gem path
@@ -257,7 +257,7 @@ module Bundler
257
257
  end
258
258
 
259
259
  def prune_git_and_path_cache(resolve, cache_path)
260
- cached = Dir["#{cache_path}/*/.bundlecache"]
260
+ cached = SharedHelpers.glob_files_in_dir("*/.bundlecache", cache_path.to_s)
261
261
 
262
262
  cached = cached.delete_if do |path|
263
263
  name = File.basename(File.dirname(path))
@@ -283,7 +283,7 @@ module Bundler
283
283
  # Add man/ subdirectories from activated bundles to MANPATH for man(1)
284
284
  manuals = $LOAD_PATH.filter_map do |path|
285
285
  man_subdir = path.sub(/lib$/, "man")
286
- man_subdir unless Dir[man_subdir + "/man?/"].empty?
286
+ man_subdir unless SharedHelpers.glob_files_in_dir("man?/", man_subdir).empty?
287
287
  end
288
288
 
289
289
  return if manuals.empty?