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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +174 -0
- data/bundler.gemspec +2 -2
- data/lib/bundler/build_metadata.rb +2 -2
- data/lib/bundler/cli/add.rb +3 -0
- data/lib/bundler/cli/check.rb +1 -3
- data/lib/bundler/cli/common.rb +17 -0
- data/lib/bundler/cli/config.rb +8 -3
- data/lib/bundler/cli/doctor/diagnose.rb +1 -1
- data/lib/bundler/cli/exec.rb +15 -1
- data/lib/bundler/cli/gem.rb +2 -3
- data/lib/bundler/cli/init.rb +4 -0
- data/lib/bundler/cli/install.rb +4 -0
- data/lib/bundler/cli/issue.rb +2 -2
- data/lib/bundler/cli/lock.rb +10 -2
- data/lib/bundler/cli/open.rb +12 -2
- data/lib/bundler/cli/outdated.rb +76 -6
- data/lib/bundler/cli/update.rb +3 -0
- data/lib/bundler/cli.rb +21 -15
- data/lib/bundler/compact_index_client/cache.rb +11 -0
- data/lib/bundler/compact_index_client/cache_file.rb +1 -1
- data/lib/bundler/compact_index_client/parser.rb +4 -1
- data/lib/bundler/definition.rb +64 -15
- data/lib/bundler/dsl.rb +11 -4
- data/lib/bundler/endpoint_specification.rb +30 -2
- data/lib/bundler/fetcher/base.rb +3 -0
- data/lib/bundler/fetcher/compact_index.rb +13 -0
- data/lib/bundler/fetcher/gem_remote_fetcher.rb +2 -0
- data/lib/bundler/fetcher.rb +12 -2
- data/lib/bundler/gem_helper.rb +2 -2
- data/lib/bundler/installer/parallel_installer.rb +68 -23
- data/lib/bundler/installer.rb +18 -1
- data/lib/bundler/lockfile_generator.rb +55 -1
- data/lib/bundler/lockfile_parser.rb +23 -1
- data/lib/bundler/man/bundle-add.1 +6 -3
- data/lib/bundler/man/bundle-add.1.ronn +7 -2
- data/lib/bundler/man/bundle-binstubs.1 +1 -1
- data/lib/bundler/man/bundle-cache.1 +4 -1
- data/lib/bundler/man/bundle-cache.1.ronn +6 -0
- data/lib/bundler/man/bundle-check.1 +1 -1
- data/lib/bundler/man/bundle-clean.1 +1 -1
- data/lib/bundler/man/bundle-config.1 +121 -157
- data/lib/bundler/man/bundle-config.1.ronn +38 -6
- data/lib/bundler/man/bundle-console.1 +3 -3
- data/lib/bundler/man/bundle-console.1.ronn +2 -2
- data/lib/bundler/man/bundle-doctor.1 +1 -1
- data/lib/bundler/man/bundle-env.1 +1 -1
- data/lib/bundler/man/bundle-exec.1 +1 -1
- data/lib/bundler/man/bundle-fund.1 +1 -1
- data/lib/bundler/man/bundle-gem.1 +1 -1
- data/lib/bundler/man/bundle-help.1 +1 -1
- data/lib/bundler/man/bundle-info.1 +1 -1
- data/lib/bundler/man/bundle-init.1 +2 -2
- data/lib/bundler/man/bundle-init.1.ronn +1 -1
- data/lib/bundler/man/bundle-install.1 +5 -2
- data/lib/bundler/man/bundle-install.1.ronn +9 -1
- data/lib/bundler/man/bundle-issue.1 +1 -1
- data/lib/bundler/man/bundle-licenses.1 +1 -1
- data/lib/bundler/man/bundle-list.1 +1 -1
- data/lib/bundler/man/bundle-lock.1 +5 -2
- data/lib/bundler/man/bundle-lock.1.ronn +6 -0
- data/lib/bundler/man/bundle-open.1 +1 -1
- data/lib/bundler/man/bundle-outdated.1 +17 -14
- data/lib/bundler/man/bundle-outdated.1.ronn +25 -12
- data/lib/bundler/man/bundle-platform.1 +5 -1
- data/lib/bundler/man/bundle-platform.1.ronn +4 -0
- data/lib/bundler/man/bundle-plugin.1 +2 -2
- data/lib/bundler/man/bundle-plugin.1.ronn +1 -1
- data/lib/bundler/man/bundle-pristine.1 +1 -1
- data/lib/bundler/man/bundle-remove.1 +1 -1
- data/lib/bundler/man/bundle-show.1 +1 -1
- data/lib/bundler/man/bundle-update.1 +5 -2
- data/lib/bundler/man/bundle-update.1.ronn +8 -0
- data/lib/bundler/man/bundle-version.1 +1 -1
- data/lib/bundler/man/bundle.1 +23 -2
- data/lib/bundler/man/bundle.1.ronn +22 -1
- data/lib/bundler/man/gemfile.5 +14 -3
- data/lib/bundler/man/gemfile.5.ronn +23 -2
- data/lib/bundler/mirror.rb +7 -7
- data/lib/bundler/plugin/unloaded_source.rb +25 -0
- data/lib/bundler/plugin.rb +12 -6
- data/lib/bundler/remote_specification.rb +1 -1
- data/lib/bundler/resolver/base.rb +9 -0
- data/lib/bundler/resolver.rb +116 -2
- data/lib/bundler/rubygems_ext.rb +22 -0
- data/lib/bundler/rubygems_gem_installer.rb +35 -2
- data/lib/bundler/runtime.rb +11 -11
- data/lib/bundler/self_manager.rb +6 -1
- data/lib/bundler/settings.rb +1 -0
- data/lib/bundler/shared_helpers.rb +21 -1
- data/lib/bundler/source/git/git_proxy.rb +83 -10
- data/lib/bundler/source/git.rb +4 -4
- data/lib/bundler/source/metadata.rb +4 -0
- data/lib/bundler/source/path.rb +4 -3
- data/lib/bundler/source/rubygems/remote.rb +12 -2
- data/lib/bundler/source/rubygems.rb +81 -13
- data/lib/bundler/source_list.rb +44 -3
- data/lib/bundler/spec_set.rb +2 -2
- data/lib/bundler/stub_specification.rb +3 -1
- data/lib/bundler/templates/newgem/newgem.gemspec.tt +8 -2
- data/lib/bundler/vendored_tsort.rb +19 -2
- data/lib/bundler/version.rb +1 -1
- data/lib/bundler.rb +26 -6
- metadata +5 -6
- data/lib/bundler/vendor/tsort/LICENSE.txt +0 -22
- data/lib/bundler/vendor/tsort/lib/tsort.rb +0 -455
data/lib/bundler/man/bundle.1
CHANGED
|
@@ -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" "
|
|
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
|
|
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
|
|
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
|
|
data/lib/bundler/man/gemfile.5
CHANGED
|
@@ -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" "
|
|
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
|
|
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 [
|
|
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,
|
data/lib/bundler/mirror.rb
CHANGED
|
@@ -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
|
|
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
|
|
170
|
-
socket
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
data/lib/bundler/plugin.rb
CHANGED
|
@@ -4,11 +4,12 @@ require_relative "plugin/api"
|
|
|
4
4
|
|
|
5
5
|
module Bundler
|
|
6
6
|
module Plugin
|
|
7
|
-
autoload :DSL,
|
|
8
|
-
autoload :Events,
|
|
9
|
-
autoload :Index,
|
|
10
|
-
autoload :Installer,
|
|
11
|
-
autoload :SourceList,
|
|
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(
|
|
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
|
data/lib/bundler/resolver.rb
CHANGED
|
@@ -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) }
|
data/lib/bundler/rubygems_ext.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
data/lib/bundler/runtime.rb
CHANGED
|
@@ -141,7 +141,7 @@ module Bundler
|
|
|
141
141
|
end
|
|
142
142
|
end
|
|
143
143
|
|
|
144
|
-
|
|
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 =
|
|
163
|
-
git_dirs =
|
|
164
|
-
git_cache_dirs =
|
|
165
|
-
gem_dirs =
|
|
166
|
-
gem_files =
|
|
167
|
-
gemspec_files =
|
|
168
|
-
extension_dirs =
|
|
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 =
|
|
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 =
|
|
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
|
|
286
|
+
man_subdir unless SharedHelpers.glob_files_in_dir("man?/", man_subdir).empty?
|
|
287
287
|
end
|
|
288
288
|
|
|
289
289
|
return if manuals.empty?
|