bundle_matrix 0.1.0
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 +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +149 -0
- data/lib/bundle_matrix/argv_parser.rb +160 -0
- data/lib/bundle_matrix/bundle +13 -0
- data/lib/bundle_matrix/config.rb +40 -0
- data/lib/bundle_matrix/discovery.rb +58 -0
- data/lib/bundle_matrix/hook.rb +55 -0
- data/lib/bundle_matrix/invocation.rb +77 -0
- data/lib/bundle_matrix/planner.rb +152 -0
- data/lib/bundle_matrix/project_settings.rb +32 -0
- data/lib/bundle_matrix/runner.rb +111 -0
- data/lib/bundle_matrix/secondary_gemfile.rb +57 -0
- data/lib/bundle_matrix/version.rb +13 -0
- data/lib/bundle_matrix.rb +21 -0
- data/plugins.rb +7 -0
- data/sig/bundle_matrix.rbs +214 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4167a86314145a5afc95384b77c4b3820a623578eb4d1d579a526e4150dd82af
|
|
4
|
+
data.tar.gz: b6d67d2373ba4810848712cf2d08272479771acfe4febe2b761e9d8ded79cd66
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 19b4dc2513e12c4cf9054961cd24844325b375641d6b19dd4bb7c764acd2b7dd3c7b68264e7b2ed7f372f3de2b78dec7585f29971130b4549f594b558f86c780
|
|
7
|
+
data.tar.gz: 6d52388801b2a6abcbe745166d36eef8d0048f33151ec8e8b5f21a7e5272ac7f1f56722e97b250142ba7e880da934b9fa667b647ec521d466904eb89c83a3454
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-11-08
|
|
4
|
+
|
|
5
|
+
Initial release. bundle_matrix is a Bundler plugin that repeats `bundle install`, `bundle update` and `bundle add` against every secondary gemfile in your project, so each lockfile stays in step with the main gemfile.
|
|
6
|
+
|
|
7
|
+
- Finds secondary gemfiles (`*.gemfile` in the project root and `gemfiles/`), plus `gems.rb` or `Gemfile` in any directories listed in `matrix.paths`
|
|
8
|
+
- Forwards the same flags to each gemfile, handling gems, groups, `--path` and `--lockfile` per gemfile
|
|
9
|
+
- Runs each secondary gemfile with its own lockfile's Bundler version
|
|
10
|
+
- Shares the project's `.bundle/config` and `BUNDLE_*` environment variables with every gemfile
|
|
11
|
+
- Recognizes command aliases and prefixes (`i`, `a`, `u`) and plain `bundle`, as Bundler does
|
|
12
|
+
- Reports a gemfile that fails as an error and keeps going with the rest
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 joe-sharp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# BundleMatrix
|
|
2
|
+
|
|
3
|
+
A Bundler plugin that repeats `bundle install`, `bundle update` and `bundle add` against every other gemfile in your project, so each lockfile stays in step with the main `Gemfile`.
|
|
4
|
+
|
|
5
|
+
Projects that test against several dependency sets keep extra gemfiles (`ci.gemfile`, `gemfiles/rails7.gemfile`, engine Gemfiles, and so on). Without help, every one of them needs its own `BUNDLE_GEMFILE=… bundle install`. With bundle_matrix, running the command on the main `Gemfile` does that for you.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Install the plugin once for your user:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bundle plugin install bundle_matrix
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or declare it in the project's `Gemfile`:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
plugin "bundle_matrix"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Run the command on the main `Gemfile` as usual:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
bundle install
|
|
29
|
+
bundle update rails --conservative
|
|
30
|
+
bundle update --all
|
|
31
|
+
bundle add rack --group development
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
After the main run succeeds, each discovered gemfile is run with the same command and flags. Output is headed by the gemfile it applies to:
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
== bundle_matrix: gemfiles/rails7.gemfile -> bundle update rails --conservative
|
|
38
|
+
== bundle_matrix: engines/admin/Gemfile -> bundle install
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Only `install`, `update` and `add` are fanned out. `bundle check`, `bundle exec` and similar commands are not, because Bundler does not run plugin hooks for them.
|
|
42
|
+
|
|
43
|
+
Bundler's short forms work too: `bundle i`, `bundle ins`, `bundle a`, `bundle u`, and any other unambiguous prefix of a command name.
|
|
44
|
+
|
|
45
|
+
A plain `bundle`, or `bundle` with only flags such as `bundle --jobs=4`, runs as `install` and fans out as `install`, but only while `default_cli_command` is unset or `install`. With it set to `cli_help`, those commands print help and nothing fans out. Bundler's documentation already names `cli_help` as the default, while its code still runs `install` and prints a notice that this will change. To keep fanning out whatever Bundler does, set the setting explicitly, or run `bundle install`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bundle config set default_cli_command install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Which gemfiles are found
|
|
52
|
+
|
|
53
|
+
The main gemfile is `Gemfile` in the project root, or the file named by `matrix.default_gemfile`. It is never treated as a secondary gemfile. Secondary gemfiles are found in:
|
|
54
|
+
|
|
55
|
+
- `*.gemfile` in the project root, e.g. `ci.gemfile` with `ci.gemfile.lock`
|
|
56
|
+
- `*.gemfile` in `gemfiles/`
|
|
57
|
+
- any directories listed in `matrix.paths`, each contributing its `gems.rb`, or its `Gemfile` when it has no `gems.rb`, the order Bundler uses
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
Settings use `bundle config` or `BUNDLE_MATRIX__*` environment variables:
|
|
62
|
+
|
|
63
|
+
| Setting | Default | Meaning |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| `matrix.default_gemfile` | `Gemfile` | File name of the main gemfile in the project root. Fan-out starts only when the command runs on this file, e.g. `gems.rb` |
|
|
66
|
+
| `matrix.paths` | none | Extra directories or globs to search for a `gems.rb` or `Gemfile`, separated by commas, e.g. `engines/*,apps/admin` |
|
|
67
|
+
| `matrix.disabled` | `false` | Turn fan-out off |
|
|
68
|
+
| `matrix.fail_fast` | `false` | Stop at the first failing gemfile |
|
|
69
|
+
| `matrix.bundler_warnings` | `true` | Warn when a gemfile runs a different Bundler from the main Gemfile. Set it to `false` for projects that pin different Bundlers on purpose |
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bundle config set matrix.paths "engines/*,apps/admin"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
For a project whose main file is `gems.rb`, set the main gemfile name once, and run the command on `gems.rb`:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
bundle config set matrix.default_gemfile gems.rb
|
|
79
|
+
bundle install
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To skip the matrix for a single run, set `BUNDLE_MATRIX__DISABLED` on that command:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
BUNDLE_MATRIX__DISABLED=1 bundle install
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`bundle config set matrix.disabled true` is the persistent version, and stays in effect until you change it back.
|
|
89
|
+
|
|
90
|
+
## How each command is applied
|
|
91
|
+
|
|
92
|
+
Flags are forwarded as typed, and Bundler's own option table decides what each flag means. The exceptions below are where a flag can't be applied to another gemfile as-is.
|
|
93
|
+
|
|
94
|
+
- **`install`**: `--lockfile` and `--standalone` only apply to the main Gemfile, so they are not forwarded.
|
|
95
|
+
- **`update <gems>`**: only the named gems that appear in that gemfile's lockfile are updated. A gemfile with none of them is skipped. `--group` is limited to groups it declares. If it has no lockfile yet, `install` is run to create one.
|
|
96
|
+
- **`update --all`**, `--conservative`, `--patch`, `--minor`, `--major`, `--pre`, `--strict` and the rest: forwarded unchanged.
|
|
97
|
+
- **`add <gems>`**: gems the gemfile already declares, including through `eval_gemfile`, are not added again. If every gem is already declared, `install` is run to re-lock the gemfile. A relative `--path` is rewritten so it resolves from that gemfile's directory.
|
|
98
|
+
- `--gemfile` is never forwarded. The target is set through `BUNDLE_GEMFILE` instead.
|
|
99
|
+
|
|
100
|
+
## Settings and Bundler versions
|
|
101
|
+
|
|
102
|
+
Each gemfile is run the way `bundle` would run it on its own, with the project's settings:
|
|
103
|
+
|
|
104
|
+
- The project's `.bundle/config` and the `BUNDLE_*` environment variables you set are passed on, so `frozen`, `without`, `jobs` and the rest apply to every gemfile. A relative `path`, `bin` or `cache_path` still resolves from the project root, so every gemfile shares the same `vendor/bundle`. A `.bundle/config` next to a secondary gemfile takes priority for that gemfile.
|
|
105
|
+
- `gemfile` and `lockfile` are not passed on, because they name the main Gemfile's own files.
|
|
106
|
+
- Each gemfile runs with the Bundler version in its own `BUNDLED WITH` line, and `install` installs that version first if it is missing. A `gem "bundler"` line in a gemfile that has no lockfile yet is not installed for you, so install that version yourself first. Bundler's `version` setting controls this: `bundle config set version system` keeps every gemfile on the running version, and `bundle config set version 4.0.23` pins one. `BUNDLER_VERSION` is not passed on, because it would hold every gemfile to one version.
|
|
107
|
+
- When a gemfile runs with a different Bundler from the main Gemfile, a one-line warning names both versions, and the run ends by suggesting either `bundle update --bundler`, which updates Bundler in every lockfile, or `bundle config set matrix.bundler_warnings false`, which turns these warnings off when each gemfile should keep its own Bundler.
|
|
108
|
+
|
|
109
|
+
## How it uses Bundler
|
|
110
|
+
|
|
111
|
+
bundle_matrix needs Bundler 2.6.9 or newer, below 4.2 and its prereleases, and is tested against 2.6.9, 2.7.2, 4.0.24 and 4.1.0.beta3. The upper bound moves up once a new Bundler minor has been tested. Under any other Bundler the plugin still runs, but prints a one-line warning that it is untested there, with a link to the issue tracker.
|
|
112
|
+
|
|
113
|
+
Fan-out starts from Bundler's documented plugin hook, `after-install-all`, and each gemfile runs in its own `bundle` process. Everything else uses Bundler classes that work today but are not a plugin API, so a new Bundler release could change them:
|
|
114
|
+
|
|
115
|
+
- Thor's command and option tables (`Bundler::CLI`), which decide what each flag means and which command was run (`ArgvParser`, `Invocation`). Command names such as `ins` are resolved by calling Thor's protected `normalize_command_name` with `send`
|
|
116
|
+
- `Bundler::Dsl` and `Bundler::LockfileParser`, which read each secondary gemfile and lockfile (`Planner`). `Bundler.definition` and `Bundler.locked_gems` only describe the main Gemfile, so they can't be used for the others.
|
|
117
|
+
- The settings' storage levels, through `Bundler.settings` (`ProjectSettings`)
|
|
118
|
+
|
|
119
|
+
## Limitations
|
|
120
|
+
|
|
121
|
+
- `bundle check` is not supported. Bundler only calls plugin hooks around installation.
|
|
122
|
+
- `bundle add --skip-install` never runs the hook, because no installation happens.
|
|
123
|
+
- Flags that Bundler does not know about are forwarded as-is, and any value after them is treated as a gem name.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
Run `bin/setup`, then `rake`. The default task runs the unit specs, the end-to-end specs, RuboCop, `rake rbs`, which runs `rbs validate` on the signatures in `sig/` with the Bundler stubs in `sig_stubs/`, and `rake steep`, which type checks `lib/` against those signatures. Run `rake e2e` on its own for the end-to-end specs, which run real `bundle` commands and take longer. Run `COVERAGE=true bundle exec rake spec` to also write a coverage report to `coverage/index.html`. That run fails below 90% line coverage or 80% branch coverage, and CI runs it this way.
|
|
128
|
+
|
|
129
|
+
Run `bundle exec guard` to re-run the unit specs and RuboCop on every change. It also regenerates the docs when the files they are built from change.
|
|
130
|
+
|
|
131
|
+
`docs/` is generated by Boondoc, so edit `README.md` and the code comments rather than `docs/`. To regenerate it by hand, run `bundle exec boondoc`
|
|
132
|
+
|
|
133
|
+
To try a change against a real project, install from a local checkout with `bundle plugin install bundle_matrix --path /path/to/bundle_matrix` (the directory must contain `plugins.rb`).
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
Bug reports and pull requests are welcome. Send issues and pull requests to https://github.com/joe-sharp/bundle_matrix.
|
|
138
|
+
|
|
139
|
+
Run `bundle exec rake` before you open a pull request. The Development section above explains what it runs.
|
|
140
|
+
|
|
141
|
+
A bug report should include:
|
|
142
|
+
|
|
143
|
+
- the Bundler version, from `bundle -v`
|
|
144
|
+
- the command you ran, e.g. `bundle update rails --conservative`
|
|
145
|
+
- your gemfile layout: which gemfiles exist, where they are, and any `matrix.*` settings
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/cli"
|
|
4
|
+
|
|
5
|
+
module BundleMatrix
|
|
6
|
+
# Finds the command in argv the way Bundler does, then splits the tokens after it into
|
|
7
|
+
# options, gems and unknown switches, using the switch table Bundler itself uses for that command.
|
|
8
|
+
# Every touch of Bundler::CLI is in this class, so the rest of the plugin only sees command names and tokens.
|
|
9
|
+
class ArgvParser
|
|
10
|
+
Switch = Data.define(:name, :option, :negated)
|
|
11
|
+
|
|
12
|
+
# One switch Bundler accepts for a command; negated is true for its `--no-name` form
|
|
13
|
+
class Switch
|
|
14
|
+
def initialize(name:, option:, negated: false)
|
|
15
|
+
super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
INSTALL_ALIASES = %w[install install_or_cli_help].freeze
|
|
20
|
+
|
|
21
|
+
# @return [Array(String, Array<String>)] the command Bundler would run (nil when unknown) and the tokens after it
|
|
22
|
+
def self.split_command(argv)
|
|
23
|
+
first, *rest = argv
|
|
24
|
+
return [resolve_command(first), rest] unless default_command_used?(first)
|
|
25
|
+
|
|
26
|
+
command = resolve_command(Bundler.settings[:default_cli_command] || "install")
|
|
27
|
+
# Bundler runs install for its deprecated install_or_cli_help alias too
|
|
28
|
+
[INSTALL_ALIASES.include?(command) ? "install" : command, argv]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Bundler runs its default command when argv is empty or starts with a switch
|
|
32
|
+
# (`bundle`, `bundle --jobs=4`), and then every token is an argument. A mapped
|
|
33
|
+
# switch such as `-v` is still a command of its own. With default_cli_command unset,
|
|
34
|
+
# Bundler 4 still runs install here, with a notice that this will change. Its docs
|
|
35
|
+
# already name cli_help as the default, but the code does not, so this follows the code.
|
|
36
|
+
# The setting is read directly, because Bundler::CLI.default_command prints that notice again.
|
|
37
|
+
def self.default_command_used?(token)
|
|
38
|
+
token.nil? || (token.start_with?("-") && !Bundler::CLI.map.key?(token))
|
|
39
|
+
end
|
|
40
|
+
private_class_method :default_command_used?
|
|
41
|
+
|
|
42
|
+
# Thor has no public method for prefix lookup (`i`, `ins`, `a`, `u`), so this calls its protected
|
|
43
|
+
# normalize_command_name. The code already relies on Thor's tables, and calling the method keeps
|
|
44
|
+
# the result identical to Bundler's, where a copy could drift without anyone noticing.
|
|
45
|
+
def self.resolve_command(token)
|
|
46
|
+
return unless token
|
|
47
|
+
|
|
48
|
+
Bundler::CLI.send(:normalize_command_name, token)
|
|
49
|
+
rescue Bundler::Thor::AmbiguousTaskError
|
|
50
|
+
# Bundler rejects an ambiguous name before installing, so the hook never sees one. This is only a safeguard.
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
private_class_method :resolve_command
|
|
54
|
+
|
|
55
|
+
# @return [Array(Array<Invocation::Option>, Array<String>, Array<String>)]
|
|
56
|
+
def self.call(command, tokens)
|
|
57
|
+
new(table_for(command)).call(tokens)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Bundler's global options (--retry, --verbose, --no-color) apply to every command, so they are added to its own.
|
|
61
|
+
# Thor's tables are not a plugin API, so a Bundler release can change them; the Appraisals cover each version.
|
|
62
|
+
def self.table_for(command)
|
|
63
|
+
Bundler::CLI.class_options.merge(Bundler::CLI.commands.fetch(command).options)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def initialize(table)
|
|
67
|
+
@switches = switch_table(table)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def call(tokens)
|
|
71
|
+
switch_tokens, literal_gems = split_terminator(tokens)
|
|
72
|
+
options, gems, unknown = collect(switch_tokens.dup)
|
|
73
|
+
[options, gems + literal_gems, unknown]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def collect(rest)
|
|
79
|
+
options = []
|
|
80
|
+
gems = []
|
|
81
|
+
unknown = []
|
|
82
|
+
|
|
83
|
+
until rest.empty?
|
|
84
|
+
token = rest.shift
|
|
85
|
+
next gems << token unless switch?(token)
|
|
86
|
+
|
|
87
|
+
option = read_option(token, rest)
|
|
88
|
+
option ? options << option : unknown << token
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
[options, gems, unknown]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def switch_table(table)
|
|
95
|
+
switches = table.each_with_object({}) do |(name, option), lookup|
|
|
96
|
+
lookup["--#{name}"] = Switch.new(name: name, option: option)
|
|
97
|
+
option.aliases.each { |alias_name| lookup[alias_name] = Switch.new(name: name, option: option) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
table.each do |name, option|
|
|
101
|
+
next unless option.type == :boolean
|
|
102
|
+
|
|
103
|
+
switches["--no-#{name}"] ||= Switch.new(name: name, option: option, negated: true)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
switches
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def switch?(token)
|
|
110
|
+
token.start_with?("-") && token != "-"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Everything after a bare `--` is a gem name, whatever it looks like
|
|
114
|
+
def split_terminator(tokens)
|
|
115
|
+
index = tokens.index("--")
|
|
116
|
+
return [tokens, []] unless index
|
|
117
|
+
|
|
118
|
+
[tokens.take(index), tokens.drop(index + 1)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Consumes the values that belong to a switch from the front of rest
|
|
122
|
+
def read_option(token, rest)
|
|
123
|
+
key, inline = split_switch(token)
|
|
124
|
+
switch = @switches[key]
|
|
125
|
+
return unless switch
|
|
126
|
+
|
|
127
|
+
values, used = values_for(switch.option, inline, rest)
|
|
128
|
+
Invocation::Option.new(
|
|
129
|
+
name: switch.name, tokens: [token, *rest.shift(used)], values: values, negated: switch.negated
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# `--name=value`, and `-jvalue` for single-dash switches
|
|
134
|
+
def split_switch(token)
|
|
135
|
+
key, separator, value = token.partition("=")
|
|
136
|
+
value = nil if separator.empty?
|
|
137
|
+
return [key, value] if @switches.key?(key) || !token.match?(/\A-[^-]/)
|
|
138
|
+
|
|
139
|
+
short = token[0, 2].to_s
|
|
140
|
+
@switches.key?(short) ? [short, token[2..]] : [key, value]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Array(Array<String>, Integer)] the values and how many following tokens they used
|
|
144
|
+
def values_for(option, inline, rest)
|
|
145
|
+
return array_values(rest, [inline]) if option.type == :array && inline
|
|
146
|
+
return [[inline], 0] if inline
|
|
147
|
+
return [[], 0] if option.type == :boolean
|
|
148
|
+
return array_values(rest, []) if option.type == :array
|
|
149
|
+
return [[], 0] if rest.empty? || (option.lazy_default && switch?(rest.first))
|
|
150
|
+
|
|
151
|
+
[[rest.first], 1]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# An array option takes every value up to the next switch, after any value given inline with `=`
|
|
155
|
+
def array_values(rest, leading)
|
|
156
|
+
values = rest.take_while { |token| !switch?(token) }
|
|
157
|
+
[leading + values, values.size]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The same as RubyGems' `bundle` binstub. Loading Bundler through RubyGems picks
|
|
4
|
+
# the Bundler version for each gemfile from BUNDLER_VERSION, the `version`
|
|
5
|
+
# setting or its lockfile's BUNDLED WITH line, as running `bundle` would.
|
|
6
|
+
# Kept in lib/ so it is not installed as an executable of this gem.
|
|
7
|
+
require "rubygems"
|
|
8
|
+
|
|
9
|
+
if Gem.respond_to?(:activate_and_load_bin_path)
|
|
10
|
+
Gem.activate_and_load_bin_path("bundler", "bundle", ">= 0.a")
|
|
11
|
+
else
|
|
12
|
+
load Gem.activate_bin_path("bundler", "bundle", ">= 0.a")
|
|
13
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# Reads the matrix.* settings, from `bundle config` or BUNDLE_MATRIX__* env vars.
|
|
5
|
+
class Config
|
|
6
|
+
TRUE_VALUES = %w[true 1 yes].freeze
|
|
7
|
+
FALSE_VALUES = %w[false 0 no].freeze
|
|
8
|
+
DEFAULT_GEMFILE = "Gemfile"
|
|
9
|
+
|
|
10
|
+
# Extra directories (or globs) to search for a Gemfile, e.g. "engines/*,apps/admin".
|
|
11
|
+
def paths
|
|
12
|
+
Bundler.settings["matrix.paths"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The file name of the main gemfile in the project root, e.g. "gems.rb". Fan-out starts from it.
|
|
16
|
+
def default_gemfile
|
|
17
|
+
value = Bundler.settings["matrix.default_gemfile"].to_s.strip
|
|
18
|
+
value.empty? ? DEFAULT_GEMFILE : value
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def disabled?
|
|
22
|
+
truthy?("matrix.disabled")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fail_fast?
|
|
26
|
+
truthy?("matrix.fail_fast")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Warn when a gemfile runs a different Bundler from the main one; on unless set to false, 0 or no
|
|
30
|
+
def bundler_warnings?
|
|
31
|
+
!FALSE_VALUES.include?(Bundler.settings["matrix.bundler_warnings"].to_s.downcase)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def truthy?(key)
|
|
37
|
+
TRUE_VALUES.include?(Bundler.settings[key].to_s.downcase)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# Finds the secondary gemfiles that follow the main Gemfile.
|
|
5
|
+
class Discovery
|
|
6
|
+
# The gemfile a matrix.paths directory contributes, in Bundler's order
|
|
7
|
+
DIRECTORY_GEMFILES = %w[gems.rb Gemfile].freeze
|
|
8
|
+
|
|
9
|
+
# default_gemfile is the main gemfile's file name in root; it is never returned, whatever its name
|
|
10
|
+
def initialize(root, paths, default_gemfile:)
|
|
11
|
+
@root = Pathname(root).expand_path
|
|
12
|
+
@paths = paths
|
|
13
|
+
@default_gemfile = default_gemfile
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<Pathname>] sorted, unique, excluding the main gemfile
|
|
17
|
+
def gemfiles
|
|
18
|
+
found = glob("*.gemfile") + glob("gemfiles/*.gemfile") + configured_gemfiles
|
|
19
|
+
|
|
20
|
+
found.map(&:expand_path).uniq.sort.reject { |path| main_gemfile?(path) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def default_gemfile_path
|
|
26
|
+
@root.join(@default_gemfile)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compares files, not paths, so a symlink to the main gemfile is excluded too
|
|
30
|
+
def main_gemfile?(path)
|
|
31
|
+
File.identical?(path, default_gemfile_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Patterns are relative to the root, so glob characters in the project's own path are not special
|
|
35
|
+
def glob(pattern)
|
|
36
|
+
Dir.glob(pattern, base: @root.to_s).map { |match| @root.join(match) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def configured_gemfiles
|
|
40
|
+
@paths.flat_map do |entry|
|
|
41
|
+
found = glob(entry).flat_map { |match| gemfiles_at(match) }
|
|
42
|
+
Bundler.ui.warn("bundle_matrix: matrix.paths entry #{entry.inspect} matched no Gemfile") if found.empty?
|
|
43
|
+
found
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def gemfiles_at(path)
|
|
48
|
+
if path.directory?
|
|
49
|
+
gemfile = DIRECTORY_GEMFILES.map { |name| path.join(name) }.find(&:file?)
|
|
50
|
+
gemfile ? [gemfile] : []
|
|
51
|
+
elsif path.file? && path.basename.to_s.match?(/(gemfile|\Agems\.rb)\z/i)
|
|
52
|
+
[path]
|
|
53
|
+
else
|
|
54
|
+
[]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# Called by Bundler after `install`, `update` or `add` has finished.
|
|
5
|
+
module Hook
|
|
6
|
+
ISSUES_URL = "https://github.com/joe-sharp/bundle_matrix/issues"
|
|
7
|
+
|
|
8
|
+
# Checked before parsing, so a disabled plugin or a child run never depends on how Bundler's CLI parses
|
|
9
|
+
def self.call(argv = ARGV)
|
|
10
|
+
return if secondary_run?
|
|
11
|
+
|
|
12
|
+
config = Config.new
|
|
13
|
+
return if config.disabled?
|
|
14
|
+
|
|
15
|
+
invocation = Invocation.parse(argv)
|
|
16
|
+
return unless invocation && default_gemfile?(config)
|
|
17
|
+
|
|
18
|
+
root = Bundler.root
|
|
19
|
+
gemfiles = Discovery.new(root, config.paths, default_gemfile: config.default_gemfile).gemfiles
|
|
20
|
+
return if gemfiles.empty?
|
|
21
|
+
|
|
22
|
+
warn_untested_bundler
|
|
23
|
+
|
|
24
|
+
runner = Runner.new(root, fail_fast: config.fail_fast?, bundler_warnings: config.bundler_warnings?)
|
|
25
|
+
runner.run(plans_for(invocation, gemfiles, root))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.plans_for(invocation, gemfiles, root)
|
|
29
|
+
gemfiles.to_h do |gemfile|
|
|
30
|
+
[gemfile, Planner.new(invocation, gemfile, root).plan]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Fan-out still runs under an untested Bundler, but asks to hear about anything that breaks
|
|
35
|
+
def self.warn_untested_bundler
|
|
36
|
+
return if TESTED_BUNDLER.satisfied_by?(Gem::Version.new(Bundler::VERSION))
|
|
37
|
+
|
|
38
|
+
Bundler.ui.warn(
|
|
39
|
+
"bundle_matrix: not tested with Bundler #{Bundler::VERSION}; " \
|
|
40
|
+
"if anything goes wrong, please open an issue at #{ISSUES_URL}"
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.default_gemfile?(config)
|
|
45
|
+
Bundler.default_gemfile.basename.to_s == config.default_gemfile
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Child runs set this, so they never fan out again
|
|
49
|
+
def self.secondary_run?
|
|
50
|
+
!ENV["BUNDLE_MATRIX_ACTIVE"].to_s.empty?
|
|
51
|
+
end
|
|
52
|
+
private_class_method :default_gemfile?, :secondary_run?, :plans_for, :warn_untested_bundler
|
|
53
|
+
private_constant :ISSUES_URL
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# A parsed `bundle install|update|add` command line.
|
|
5
|
+
#
|
|
6
|
+
# Option names, aliases and value types come from Bundler's own Thor tables,
|
|
7
|
+
# so every flag Bundler accepts is understood, and the raw tokens are kept
|
|
8
|
+
# so they can be forwarded exactly as the user typed them.
|
|
9
|
+
class Invocation < Data.define(:command, :options, :gems, :unknown)
|
|
10
|
+
COMMANDS = %w[install update add].freeze
|
|
11
|
+
|
|
12
|
+
Option = Data.define(:name, :tokens, :values, :negated)
|
|
13
|
+
|
|
14
|
+
# negated is true for a `--no-name` switch, which unsets the option rather than setting it
|
|
15
|
+
class Option
|
|
16
|
+
def initialize(name:, tokens:, values:, negated: false)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Invocation, nil] nil when the command is not one we fan out
|
|
22
|
+
def self.parse(argv)
|
|
23
|
+
command, tokens = ArgvParser.split_command(argv)
|
|
24
|
+
return unless command && COMMANDS.include?(command)
|
|
25
|
+
|
|
26
|
+
options, gems, unknown = ArgvParser.call(command, tokens)
|
|
27
|
+
new(command: command, options: options, gems: gems, unknown: unknown)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `--no-name` does not count as setting the option
|
|
31
|
+
def option?(name)
|
|
32
|
+
opt = option(name)
|
|
33
|
+
!opt.nil? && !opt.negated
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Bundler keeps the last copy of a repeated option, so that is the one that counts
|
|
37
|
+
def option(name)
|
|
38
|
+
options.reverse_each.find { |opt| opt.name == name }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def values(name)
|
|
42
|
+
option(name)&.values || []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def with_gems(new_gems)
|
|
46
|
+
with(gems: new_gems)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def without(*names)
|
|
50
|
+
with(options: options.reject { |opt| names.include?(opt.name) })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Replaces an option's values, dropping the option when none are left.
|
|
54
|
+
def replace_values(name, new_values)
|
|
55
|
+
rest = without(name)
|
|
56
|
+
return rest if new_values.empty?
|
|
57
|
+
|
|
58
|
+
added = Option.new(name: name, tokens: ["--#{name}", *new_values], values: new_values)
|
|
59
|
+
rest.with(options: rest.options + [added])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The same flags, re-targeted at another command. Only options that command
|
|
63
|
+
# understands are kept, and the gems are replaced.
|
|
64
|
+
def as(target, gems: [])
|
|
65
|
+
table = ArgvParser.table_for(target)
|
|
66
|
+
with(command: target, options: options.select { |opt| table.key?(opt.name) }, gems: gems, unknown: [])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_argv
|
|
70
|
+
flags = [*options.flat_map(&:tokens), *unknown]
|
|
71
|
+
return [command, *gems, *flags] unless gems.any? { |gem| gem.start_with?("-") }
|
|
72
|
+
|
|
73
|
+
# A gem that starts with a dash would be read as a switch, so the gems go after a `--` that ends the switches
|
|
74
|
+
[command, *flags, "--", *gems]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# Decides what to run against one secondary gemfile, given the command the
|
|
5
|
+
# user ran on the main Gemfile.
|
|
6
|
+
class Planner
|
|
7
|
+
Plan = Data.define(:invocation, :notes, :error, :locked_bundler)
|
|
8
|
+
|
|
9
|
+
# invocation is nil when the gemfile should be skipped; notes explain why; error is set when planning failed;
|
|
10
|
+
# locked_bundler is the Bundler named in this gemfile's lockfile, set on every plan that runs a command
|
|
11
|
+
class Plan
|
|
12
|
+
def initialize(invocation:, notes:, error: nil, locked_bundler: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def failed?
|
|
17
|
+
!error.nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def skipped?
|
|
21
|
+
invocation.nil? && error.nil?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Install flags that only make sense for the main Gemfile, or that Bundler no longer accepts
|
|
26
|
+
MAIN_ONLY_INSTALL_OPTIONS = %w[lockfile standalone path].freeze
|
|
27
|
+
|
|
28
|
+
# Options whose values are paths. A child resolves a relative one from its own gemfile's directory,
|
|
29
|
+
# so each is rewritten to be relative to that directory instead
|
|
30
|
+
PATH_OPTIONS = %w[binstubs path target-rbconfig].freeze
|
|
31
|
+
|
|
32
|
+
def initialize(invocation, gemfile, root)
|
|
33
|
+
@invocation = invocation.without("gemfile")
|
|
34
|
+
@gemfile = SecondaryGemfile.new(gemfile)
|
|
35
|
+
@root = Pathname(root).expand_path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def plan
|
|
39
|
+
case @invocation.command
|
|
40
|
+
when "install" then install_plan(@invocation)
|
|
41
|
+
when "update" then update_plan
|
|
42
|
+
when "add" then add_plan
|
|
43
|
+
else raise ArgumentError, "unknown command #{@invocation.command}"
|
|
44
|
+
end
|
|
45
|
+
rescue => e
|
|
46
|
+
# One gemfile that can't be read must not stop the others, so its error is reported as its own failure
|
|
47
|
+
Plan.new(invocation: nil, notes: [], error: e.message)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def install_plan(invocation, notes = [])
|
|
53
|
+
dropped = MAIN_ONLY_INSTALL_OPTIONS.select { |name| invocation.option?(name) }
|
|
54
|
+
notes += dropped.map { |name| dropped_note(name) }
|
|
55
|
+
run(invocation.without(*MAIN_ONLY_INSTALL_OPTIONS), notes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Bundler 2 saves --path to .bundle/config, which ProjectSettings passes on, so it still reaches other gemfiles
|
|
59
|
+
def dropped_note(name)
|
|
60
|
+
return "--#{name} is not forwarded to other gemfiles" unless name == "path"
|
|
61
|
+
|
|
62
|
+
"--path is not forwarded to other gemfiles, but a path Bundler saved in .bundle/config " \
|
|
63
|
+
"reaches them as BUNDLE_PATH"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def update_plan
|
|
67
|
+
return run(@invocation) if full_update?(@invocation)
|
|
68
|
+
return install_instead("no lockfile yet") unless @gemfile.locked?
|
|
69
|
+
|
|
70
|
+
narrow_update(@invocation)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Keeps only the named gems and groups that this gemfile's lockfile or dependencies know about
|
|
74
|
+
def narrow_update(invocation)
|
|
75
|
+
gems = invocation.gems & @gemfile.lock_names
|
|
76
|
+
# --source names gems too, so it is narrowed to the lockfile the same way
|
|
77
|
+
sources = invocation.values("source") & @gemfile.lock_names
|
|
78
|
+
groups = narrow_groups(invocation)
|
|
79
|
+
if unmatched?(invocation, [*gems, *sources, *groups])
|
|
80
|
+
return skip("none of #{requested(invocation).join(", ")} are in this gemfile")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
run(invocation.with_gems(gems).replace_values("source", sources).replace_values("group", groups))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Declared groups are only read when --group was given, because reading them evaluates the whole gemfile
|
|
87
|
+
def narrow_groups(invocation)
|
|
88
|
+
requested_groups = invocation.values("group")
|
|
89
|
+
return [] if requested_groups.empty?
|
|
90
|
+
|
|
91
|
+
requested_groups & @gemfile.declared_groups
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Named gems or groups were given, but none of them apply to this gemfile
|
|
95
|
+
def unmatched?(invocation, matched)
|
|
96
|
+
requested(invocation).any? && matched.empty? &&
|
|
97
|
+
!invocation.option?("ruby") && !invocation.option?("bundler")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def requested(invocation)
|
|
101
|
+
invocation.gems + invocation.values("source") + invocation.values("group")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def add_plan
|
|
105
|
+
declared = @gemfile.declared_names
|
|
106
|
+
already = @invocation.gems & declared
|
|
107
|
+
new_gems = @invocation.gems - declared
|
|
108
|
+
notes = already.map { |name| "#{name} is already declared, not added again" }
|
|
109
|
+
|
|
110
|
+
if new_gems.empty?
|
|
111
|
+
return install_plan(@invocation.as("install"), notes + ["gems already declared, re-locking with install"])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
run(@invocation.with_gems(new_gems), notes)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def full_update?(invocation)
|
|
118
|
+
return true if invocation.option?("all")
|
|
119
|
+
|
|
120
|
+
invocation.gems.empty? && invocation.values("source").empty? && invocation.values("group").empty? &&
|
|
121
|
+
!invocation.option?("ruby") && !invocation.option?("bundler")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def install_instead(reason)
|
|
125
|
+
install_plan(@invocation.as("install"), ["#{reason}, running install to create it"])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A relative path is relative to the root in the main run, so it has to be rewritten to be relative to
|
|
129
|
+
# this gemfile's directory, where the child runs.
|
|
130
|
+
def rewrite_paths(invocation)
|
|
131
|
+
PATH_OPTIONS.reduce(invocation) do |rewritten, name|
|
|
132
|
+
values = rewritten.values(name)
|
|
133
|
+
next rewritten if values.empty?
|
|
134
|
+
|
|
135
|
+
rewritten.replace_values(name, values.map { |value| relative_to_gemfile(value) })
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def relative_to_gemfile(value)
|
|
140
|
+
absolute = File.expand_path(value, @root)
|
|
141
|
+
Bundler::SharedHelpers.relative_path_to(absolute, from: @gemfile.path.dirname)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def run(invocation, notes = [])
|
|
145
|
+
Plan.new(invocation: rewrite_paths(invocation), notes: notes, locked_bundler: @gemfile.bundler_version)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def skip(reason)
|
|
149
|
+
Plan.new(invocation: nil, notes: ["skipped, #{reason}"])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# The project's settings that a child run would otherwise lose: its .bundle/config,
|
|
5
|
+
# which a child doesn't read because it looks in its own gemfile's directory, and
|
|
6
|
+
# the BUNDLE_* variables the user set, which Bundler.with_unbundled_env strips.
|
|
7
|
+
# They are passed on as BUNDLE_* variables, so a gemfile's own .bundle/config still wins.
|
|
8
|
+
# Settings#all and #locations are not a plugin API, so a Bundler release can change them.
|
|
9
|
+
module ProjectSettings
|
|
10
|
+
# Settings that point at the main Gemfile's own files; each child sets these itself
|
|
11
|
+
MAIN_ONLY = %w[gemfile lockfile app_config bin_path].freeze
|
|
12
|
+
|
|
13
|
+
# Settings holding a path relative to the project root, which a child would
|
|
14
|
+
# otherwise resolve from its own directory
|
|
15
|
+
PATHS = %w[path bin cache_path].freeze
|
|
16
|
+
|
|
17
|
+
# @return [Hash{String => String}] BUNDLE_* variables for a child run
|
|
18
|
+
def self.env(root)
|
|
19
|
+
settings = Bundler.settings
|
|
20
|
+
user_env = Bundler.original_env
|
|
21
|
+
|
|
22
|
+
(settings.all - MAIN_ONLY).each_with_object({}) do |name, env|
|
|
23
|
+
key = settings.key_for(name)
|
|
24
|
+
# Bundler gives .bundle/config priority over the environment
|
|
25
|
+
value = settings.locations(name)[:local] || user_env[key]
|
|
26
|
+
next if value.nil?
|
|
27
|
+
|
|
28
|
+
env[key] = PATHS.include?(name) ? File.expand_path(value.to_s, root) : value.to_s
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# Runs each planned command in a separate `bundle` process. Bundler keeps
|
|
5
|
+
# global state, so the secondary gemfiles can't be run in this process.
|
|
6
|
+
class Runner
|
|
7
|
+
# Launched like a plain `bundle`, so RubyGems and Bundler pick each gemfile's Bundler version.
|
|
8
|
+
# __dir__ is only nil inside eval, which Steep can't rule out.
|
|
9
|
+
BUNDLE = File.expand_path("bundle", __dir__) # steep:ignore
|
|
10
|
+
|
|
11
|
+
# bundler_warnings is false when the matrix.bundler_warnings setting turns off the Bundler version warnings
|
|
12
|
+
def initialize(root, fail_fast: false, bundler_warnings: true)
|
|
13
|
+
@root = Pathname(root).expand_path
|
|
14
|
+
@fail_fast = fail_fast
|
|
15
|
+
@bundler_warnings = bundler_warnings
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param plans [Hash{Pathname => Planner::Plan}]
|
|
19
|
+
def run(plans)
|
|
20
|
+
failures = []
|
|
21
|
+
other_bundler = false
|
|
22
|
+
|
|
23
|
+
plans.each do |gemfile, plan|
|
|
24
|
+
label = gemfile.relative_path_from(@root)
|
|
25
|
+
# Only a plan that runs carries a locked_bundler, so a skipped or unplannable one never sets this
|
|
26
|
+
version = other_bundler_for(plan)
|
|
27
|
+
other_bundler ||= version
|
|
28
|
+
next if plan_passed?(gemfile, label, plan, version)
|
|
29
|
+
|
|
30
|
+
failures << label
|
|
31
|
+
break if @fail_fast
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
suggest_bundler_update if other_bundler
|
|
35
|
+
return if failures.empty?
|
|
36
|
+
|
|
37
|
+
raise Error, "bundle_matrix: failed for #{failures.join(", ")}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @return [Boolean] true when the plan was skipped or its command succeeded
|
|
43
|
+
def plan_passed?(gemfile, label, plan, other_bundler)
|
|
44
|
+
if plan.failed?
|
|
45
|
+
report_unplanned(label, plan.error)
|
|
46
|
+
return false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
plan.notes.each { |note| say(label, note) }
|
|
50
|
+
# A plan that neither failed nor has a command was skipped
|
|
51
|
+
invocation = plan.invocation
|
|
52
|
+
return true unless invocation
|
|
53
|
+
|
|
54
|
+
Bundler.ui.info("== bundle_matrix: #{label} -> bundle #{invocation.to_argv.join(" ")}")
|
|
55
|
+
warn_other_bundler(label, other_bundler)
|
|
56
|
+
success?(gemfile, invocation)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def say(label, message)
|
|
60
|
+
Bundler.ui.info("== bundle_matrix: #{label}: #{message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A gemfile that could not be planned is a failure, and the other gemfiles still run
|
|
64
|
+
def report_unplanned(label, error)
|
|
65
|
+
Bundler.ui.error("== bundle_matrix: #{label}: could not plan: #{error}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The Bundler a plan's lockfile switches to, when that is worth a warning; nil otherwise.
|
|
69
|
+
# A child switches only when the `version` setting is lockfile, the default. With `system` or a pinned
|
|
70
|
+
# version, every gemfile runs the same Bundler as the main one.
|
|
71
|
+
def other_bundler_for(plan)
|
|
72
|
+
version = plan.locked_bundler
|
|
73
|
+
return unless version && @bundler_warnings && [nil, "lockfile"].include?(Bundler.settings[:version])
|
|
74
|
+
|
|
75
|
+
version unless version == Gem::Version.new(Bundler::VERSION)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def warn_other_bundler(label, version)
|
|
79
|
+
return unless version
|
|
80
|
+
|
|
81
|
+
Bundler.ui.warn("bundle_matrix: #{label} uses Bundler #{version}, the main Gemfile uses #{Bundler::VERSION}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def suggest_bundler_update
|
|
85
|
+
Bundler.ui.warn(
|
|
86
|
+
"bundle_matrix: run `bundle update --bundler` to update Bundler in every lockfile, " \
|
|
87
|
+
"or `bundle config set matrix.bundler_warnings false` to silence these warnings"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def success?(gemfile, invocation)
|
|
92
|
+
Bundler.unbundled_system(
|
|
93
|
+
child_env(gemfile), Gem.ruby, BUNDLE, *invocation.to_argv, chdir: gemfile.dirname.to_s
|
|
94
|
+
) == true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# BUNDLER_VERSION would hold every gemfile to one version, and it is left set in this
|
|
98
|
+
# process when Bundler restarted itself for the main lockfile, so it is cleared.
|
|
99
|
+
def child_env(gemfile)
|
|
100
|
+
project_env.merge(
|
|
101
|
+
"BUNDLE_GEMFILE" => gemfile.to_s,
|
|
102
|
+
"BUNDLE_MATRIX_ACTIVE" => "1",
|
|
103
|
+
"BUNDLER_VERSION" => nil
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def project_env
|
|
108
|
+
@project_env ||= ProjectSettings.env(@root)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
# A secondary gemfile, and what its lockfile and declarations say about it.
|
|
5
|
+
class SecondaryGemfile
|
|
6
|
+
attr_reader :path
|
|
7
|
+
|
|
8
|
+
def initialize(path)
|
|
9
|
+
@path = Pathname(path).expand_path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Bundler locks a gems.rb in gems.locked, and every other gemfile in <gemfile>.lock
|
|
13
|
+
def lockfile
|
|
14
|
+
return path.sub_ext(".locked") if path.basename.to_s == "gems.rb"
|
|
15
|
+
|
|
16
|
+
Pathname("#{path}.lock")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def locked?
|
|
20
|
+
lockfile.file?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The Bundler this gemfile's lockfile names in BUNDLED WITH, nil without a lockfile
|
|
24
|
+
def bundler_version
|
|
25
|
+
locked&.bundler_version
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Empty without a lockfile
|
|
29
|
+
def lock_names
|
|
30
|
+
@lock_names ||= (locked&.specs || []).map(&:name).uniq
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def declared_names
|
|
34
|
+
declared.map(&:name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def declared_groups
|
|
38
|
+
declared.flat_map(&:groups).map(&:to_s).uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# nil when this gemfile has no lockfile yet. Bundler.locked_gems only reads the main Gemfile's lockfile,
|
|
44
|
+
# so LockfileParser, which is not a plugin API, reads this one.
|
|
45
|
+
def locked
|
|
46
|
+
return @locked if defined?(@locked)
|
|
47
|
+
|
|
48
|
+
@locked = locked? ? Bundler::LockfileParser.new(lockfile.read) : nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Dependencies declared by this gemfile, including those pulled in by eval_gemfile.
|
|
52
|
+
# Bundler.definition only describes the main Gemfile, so Dsl, which is not a plugin API, evaluates this one.
|
|
53
|
+
def declared
|
|
54
|
+
@declared ||= Bundler::Dsl.new.tap { |dsl| dsl.eval_gemfile(path) }.dependencies
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BundleMatrix
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
|
|
6
|
+
# The oldest Bundler the plugin runs under. The gemspec requires it, and nothing newer is refused.
|
|
7
|
+
# It is where Bundler::SharedHelpers.relative_path_to, which Planner uses, first appeared.
|
|
8
|
+
MIN_BUNDLER = "2.6.9"
|
|
9
|
+
|
|
10
|
+
# The Bundler versions the Appraisals test. The hook warns outside this range, not the gemspec.
|
|
11
|
+
# The `.a` keeps 4.2 prereleases out too; raise it once a new Bundler minor has been tested.
|
|
12
|
+
TESTED_BUNDLER = Gem::Requirement.new(">= #{MIN_BUNDLER}", "< 4.2.a")
|
|
13
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
require_relative "bundle_matrix/version"
|
|
5
|
+
require_relative "bundle_matrix/config"
|
|
6
|
+
require_relative "bundle_matrix/project_settings"
|
|
7
|
+
require_relative "bundle_matrix/discovery"
|
|
8
|
+
require_relative "bundle_matrix/argv_parser"
|
|
9
|
+
require_relative "bundle_matrix/invocation"
|
|
10
|
+
require_relative "bundle_matrix/secondary_gemfile"
|
|
11
|
+
require_relative "bundle_matrix/planner"
|
|
12
|
+
require_relative "bundle_matrix/runner"
|
|
13
|
+
require_relative "bundle_matrix/hook"
|
|
14
|
+
|
|
15
|
+
# A Bundler plugin that repeats `install`, `update` and `add` against every secondary gemfile in a project.
|
|
16
|
+
module BundleMatrix
|
|
17
|
+
# Raised after the matrix run, when one or more secondary gemfiles failed
|
|
18
|
+
class Error < Bundler::BundlerError
|
|
19
|
+
status_code(1)
|
|
20
|
+
end
|
|
21
|
+
end
|
data/plugins.rb
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
module BundleMatrix
|
|
2
|
+
VERSION: String
|
|
3
|
+
MIN_BUNDLER: String
|
|
4
|
+
TESTED_BUNDLER: Gem::Requirement
|
|
5
|
+
|
|
6
|
+
class Error < Bundler::BundlerError
|
|
7
|
+
def status_code: () -> Integer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class ArgvParser
|
|
11
|
+
class Switch < Data
|
|
12
|
+
attr_reader name: String
|
|
13
|
+
attr_reader option: Bundler::Thor::Option
|
|
14
|
+
attr_reader negated: bool
|
|
15
|
+
|
|
16
|
+
def self.new: (name: String, option: Bundler::Thor::Option, ?negated: bool) -> instance
|
|
17
|
+
def initialize: (name: String, option: Bundler::Thor::Option, ?negated: bool) -> void
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
INSTALL_ALIASES: Array[String]
|
|
21
|
+
|
|
22
|
+
@switches: Hash[String, Switch]
|
|
23
|
+
|
|
24
|
+
def self.split_command: (Array[String] argv) -> [String?, Array[String]]
|
|
25
|
+
private def self.default_command_used?: (String? token) -> bool
|
|
26
|
+
private def self.resolve_command: (String? token) -> String?
|
|
27
|
+
def self.call: (String command, Array[String] tokens) -> [Array[Invocation::Option], Array[String], Array[String]]
|
|
28
|
+
def self.table_for: (String command) -> Hash[String, Bundler::Thor::Option]
|
|
29
|
+
|
|
30
|
+
def initialize: (Hash[String, Bundler::Thor::Option] table) -> void
|
|
31
|
+
def call: (Array[String] tokens) -> [Array[Invocation::Option], Array[String], Array[String]]
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def collect: (Array[String] rest) -> [Array[Invocation::Option], Array[String], Array[String]]
|
|
36
|
+
def switch_table: (Hash[String, Bundler::Thor::Option] table) -> Hash[String, Switch]
|
|
37
|
+
def switch?: (String token) -> bool
|
|
38
|
+
def split_terminator: (Array[String] tokens) -> [Array[String], Array[String]]
|
|
39
|
+
def read_option: (String token, Array[String] rest) -> Invocation::Option?
|
|
40
|
+
def split_switch: (String token) -> [String, String?]
|
|
41
|
+
def values_for: (Bundler::Thor::Option option, String? inline, Array[String] rest) -> [Array[String], Integer]
|
|
42
|
+
def array_values: (Array[String] rest, Array[String] leading) -> [Array[String], Integer]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class Config
|
|
46
|
+
TRUE_VALUES: Array[String]
|
|
47
|
+
FALSE_VALUES: Array[String]
|
|
48
|
+
DEFAULT_GEMFILE: String
|
|
49
|
+
|
|
50
|
+
def paths: () -> Array[String]
|
|
51
|
+
def default_gemfile: () -> String
|
|
52
|
+
def disabled?: () -> bool
|
|
53
|
+
def fail_fast?: () -> bool
|
|
54
|
+
def bundler_warnings?: () -> bool
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def truthy?: (String key) -> bool
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Discovery
|
|
62
|
+
DIRECTORY_GEMFILES: Array[String]
|
|
63
|
+
|
|
64
|
+
@root: Pathname
|
|
65
|
+
@paths: Array[String]
|
|
66
|
+
@default_gemfile: String
|
|
67
|
+
|
|
68
|
+
def initialize: (Pathname | String root, Array[String] paths, default_gemfile: String) -> void
|
|
69
|
+
def gemfiles: () -> Array[Pathname]
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def default_gemfile_path: () -> Pathname
|
|
74
|
+
def main_gemfile?: (Pathname path) -> bool
|
|
75
|
+
def glob: (String pattern) -> Array[Pathname]
|
|
76
|
+
def configured_gemfiles: () -> Array[Pathname]
|
|
77
|
+
def gemfiles_at: (Pathname path) -> Array[Pathname]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class Invocation < Data
|
|
81
|
+
COMMANDS: Array[String]
|
|
82
|
+
|
|
83
|
+
class Option < Data
|
|
84
|
+
attr_reader name: String
|
|
85
|
+
attr_reader tokens: Array[String]
|
|
86
|
+
attr_reader values: Array[String]
|
|
87
|
+
attr_reader negated: bool
|
|
88
|
+
|
|
89
|
+
def self.new: (name: String, tokens: Array[String], values: Array[String], ?negated: bool) -> instance
|
|
90
|
+
def initialize: (name: String, tokens: Array[String], values: Array[String], ?negated: bool) -> void
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
attr_reader command: String
|
|
94
|
+
attr_reader options: Array[Option]
|
|
95
|
+
attr_reader gems: Array[String]
|
|
96
|
+
attr_reader unknown: Array[String]
|
|
97
|
+
|
|
98
|
+
def self.new: (command: String, options: Array[Option], gems: Array[String], unknown: Array[String]) -> instance
|
|
99
|
+
def self.parse: (Array[String] argv) -> Invocation?
|
|
100
|
+
|
|
101
|
+
def with: (?command: String, ?options: Array[Option], ?gems: Array[String], ?unknown: Array[String]) -> Invocation
|
|
102
|
+
def option?: (String name) -> bool
|
|
103
|
+
def option: (String name) -> Option?
|
|
104
|
+
def values: (String name) -> Array[String]
|
|
105
|
+
def with_gems: (Array[String] new_gems) -> Invocation
|
|
106
|
+
def without: (*String names) -> Invocation
|
|
107
|
+
def replace_values: (String name, Array[String] new_values) -> Invocation
|
|
108
|
+
def as: (String target, ?gems: Array[String]) -> Invocation
|
|
109
|
+
def to_argv: () -> Array[String]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class SecondaryGemfile
|
|
113
|
+
@locked: Bundler::LockfileParser?
|
|
114
|
+
@lock_names: Array[String]?
|
|
115
|
+
@declared: Array[Bundler::Dependency]?
|
|
116
|
+
|
|
117
|
+
attr_reader path: Pathname
|
|
118
|
+
|
|
119
|
+
def initialize: (Pathname | String path) -> void
|
|
120
|
+
def lockfile: () -> Pathname
|
|
121
|
+
def locked?: () -> bool
|
|
122
|
+
def bundler_version: () -> Gem::Version?
|
|
123
|
+
def lock_names: () -> Array[String]
|
|
124
|
+
def declared_names: () -> Array[String]
|
|
125
|
+
def declared_groups: () -> Array[String]
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def locked: () -> Bundler::LockfileParser?
|
|
130
|
+
def declared: () -> Array[Bundler::Dependency]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class Planner
|
|
134
|
+
class Plan < Data
|
|
135
|
+
attr_reader invocation: Invocation?
|
|
136
|
+
attr_reader notes: Array[String]
|
|
137
|
+
attr_reader error: String?
|
|
138
|
+
attr_reader locked_bundler: Gem::Version?
|
|
139
|
+
|
|
140
|
+
def self.new: (invocation: Invocation?, notes: Array[String], ?error: String?, ?locked_bundler: Gem::Version?) -> instance
|
|
141
|
+
def initialize: (invocation: Invocation?, notes: Array[String], ?error: String?, ?locked_bundler: Gem::Version?) -> void
|
|
142
|
+
def failed?: () -> bool
|
|
143
|
+
def skipped?: () -> bool
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
MAIN_ONLY_INSTALL_OPTIONS: Array[String]
|
|
147
|
+
PATH_OPTIONS: Array[String]
|
|
148
|
+
|
|
149
|
+
@invocation: Invocation
|
|
150
|
+
@gemfile: SecondaryGemfile
|
|
151
|
+
@root: Pathname
|
|
152
|
+
|
|
153
|
+
def initialize: (Invocation invocation, Pathname | String gemfile, Pathname | String root) -> void
|
|
154
|
+
def plan: () -> Plan
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def install_plan: (Invocation invocation, ?Array[String] notes) -> Plan
|
|
159
|
+
def dropped_note: (String name) -> String
|
|
160
|
+
def update_plan: () -> Plan
|
|
161
|
+
def narrow_update: (Invocation invocation) -> Plan
|
|
162
|
+
def narrow_groups: (Invocation invocation) -> Array[String]
|
|
163
|
+
def unmatched?: (Invocation invocation, Array[String] matched) -> bool
|
|
164
|
+
def requested: (Invocation invocation) -> Array[String]
|
|
165
|
+
def add_plan: () -> Plan
|
|
166
|
+
def full_update?: (Invocation invocation) -> bool
|
|
167
|
+
def install_instead: (String reason) -> Plan
|
|
168
|
+
def rewrite_paths: (Invocation invocation) -> Invocation
|
|
169
|
+
def relative_to_gemfile: (String value) -> String
|
|
170
|
+
def run: (Invocation invocation, ?Array[String] notes) -> Plan
|
|
171
|
+
def skip: (String reason) -> Plan
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
module ProjectSettings
|
|
175
|
+
MAIN_ONLY: Array[String]
|
|
176
|
+
PATHS: Array[String]
|
|
177
|
+
|
|
178
|
+
def self.env: (Pathname root) -> Hash[String, String]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class Runner
|
|
182
|
+
BUNDLE: String
|
|
183
|
+
|
|
184
|
+
@root: Pathname
|
|
185
|
+
@fail_fast: bool
|
|
186
|
+
@bundler_warnings: bool
|
|
187
|
+
@project_env: Hash[String, String]?
|
|
188
|
+
|
|
189
|
+
def initialize: (Pathname | String root, ?fail_fast: bool, ?bundler_warnings: bool) -> void
|
|
190
|
+
def run: (Hash[Pathname, Planner::Plan] plans) -> void
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def plan_passed?: (Pathname gemfile, Pathname label, Planner::Plan plan, Gem::Version? other_bundler) -> bool
|
|
195
|
+
def say: (Pathname label, String message) -> void
|
|
196
|
+
def report_unplanned: (Pathname label, String? error) -> void
|
|
197
|
+
def other_bundler_for: (Planner::Plan plan) -> Gem::Version?
|
|
198
|
+
def warn_other_bundler: (Pathname label, Gem::Version? version) -> void
|
|
199
|
+
def suggest_bundler_update: () -> void
|
|
200
|
+
def success?: (Pathname gemfile, Invocation invocation) -> bool
|
|
201
|
+
def child_env: (Pathname gemfile) -> Hash[String, String?]
|
|
202
|
+
def project_env: () -> Hash[String, String]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
module Hook
|
|
206
|
+
ISSUES_URL: String
|
|
207
|
+
|
|
208
|
+
def self.call: (?Array[String] argv) -> void
|
|
209
|
+
private def self.plans_for: (Invocation invocation, Array[Pathname] gemfiles, Pathname root) -> Hash[Pathname, Planner::Plan]
|
|
210
|
+
private def self.warn_untested_bundler: () -> void
|
|
211
|
+
private def self.default_gemfile?: (Config config) -> bool
|
|
212
|
+
private def self.secondary_run?: () -> bool
|
|
213
|
+
end
|
|
214
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bundle_matrix
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Joe Sharp
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bundler
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 2.6.9
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 2.6.9
|
|
26
|
+
description: A Bundler plugin. After install, update or add on the main Gemfile, it
|
|
27
|
+
replays the same command, with the same flags, against *.gemfile files in the project
|
|
28
|
+
root and gemfiles/, and any extra directories configured with matrix.paths.
|
|
29
|
+
email:
|
|
30
|
+
- joesharp13@gmail.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- CHANGELOG.md
|
|
36
|
+
- LICENSE.txt
|
|
37
|
+
- README.md
|
|
38
|
+
- lib/bundle_matrix.rb
|
|
39
|
+
- lib/bundle_matrix/argv_parser.rb
|
|
40
|
+
- lib/bundle_matrix/bundle
|
|
41
|
+
- lib/bundle_matrix/config.rb
|
|
42
|
+
- lib/bundle_matrix/discovery.rb
|
|
43
|
+
- lib/bundle_matrix/hook.rb
|
|
44
|
+
- lib/bundle_matrix/invocation.rb
|
|
45
|
+
- lib/bundle_matrix/planner.rb
|
|
46
|
+
- lib/bundle_matrix/project_settings.rb
|
|
47
|
+
- lib/bundle_matrix/runner.rb
|
|
48
|
+
- lib/bundle_matrix/secondary_gemfile.rb
|
|
49
|
+
- lib/bundle_matrix/version.rb
|
|
50
|
+
- plugins.rb
|
|
51
|
+
- sig/bundle_matrix.rbs
|
|
52
|
+
homepage: https://github.com/joe-sharp/bundle_matrix
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
homepage_uri: https://github.com/joe-sharp/bundle_matrix
|
|
57
|
+
source_code_uri: https://github.com/joe-sharp/bundle_matrix/tree/v0.1.0
|
|
58
|
+
bug_tracker_uri: https://github.com/joe-sharp/bundle_matrix/issues
|
|
59
|
+
changelog_uri: https://github.com/joe-sharp/bundle_matrix/blob/main/CHANGELOG.md
|
|
60
|
+
rubygems_mfa_required: 'true'
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 3.3.0
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 4.0.20
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: Runs bundle install, update and add against every secondary gemfile too.
|
|
78
|
+
test_files: []
|