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 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
+ ![BundleMatrix](https://raw.githubusercontent.com/joe-sharp/bundle_matrix/main/BundleMatrix.webp)
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bundle_matrix"
4
+
5
+ Bundler::Plugin::API.hook(Bundler::Plugin::Events::GEM_AFTER_INSTALL_ALL) do |_dependencies|
6
+ BundleMatrix::Hook.call
7
+ end
@@ -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: []