bundler-conservative-update 1.0.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: b1a4ccaabd989d98fa9366f6d41905b4b0f343c2bf82aab17e73e3b561ab4db0
4
+ data.tar.gz: a50dc2157b5e28562b8a1af937e5408eb58c0f25021ac3999b663078a57c3b37
5
+ SHA512:
6
+ metadata.gz: 7fcf8501218bd8f636e5b4e2174c20061f9e02f0030aaa4296e0b2f93157903a843b740bff1ad4d23fe0beac63b371ca697c8d8d75fbdbf31de5bafcfb8f7d46
7
+ data.tar.gz: 2ca8818cdd1524c7c4ad3399997e1e0d0ed381f0a2219fdb1a533f451ab80f1428838fc2d286db747a82b76c257786583ee7a2467bf3733e4477e6d9d9acb9a1
data/CHANGELOG.md ADDED
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
4
+ and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## Unreleased
7
+
8
+ ## 1.0.0 - 2026-08-05
9
+
10
+ ### Added
11
+
12
+ - Initial release: `bundle update` invocations that name gems, or a group, are
13
+ re-executed with `--conservative`, so only those gems are unlocked and
14
+ transitive dependencies stay locked.
15
+ - Invocations that name nothing to update are left to Bundler, since
16
+ `--conservative` without an explicit unlock widens the resolution to every
17
+ direct dependency: a bare `bundle update`, `--all`, `--bundler`, `--source`
18
+ and `--ruby`.
19
+ - Explicit update strategies are respected: `--conservative`, `--patch`,
20
+ `--minor`, `--major` and `--strict` all suppress the injection.
21
+ - Values of `--gemfile`, `--jobs`/`-j`, `--cooldown` and `--retry`/`-r` are not
22
+ mistaken for gem names.
23
+ - `BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1` (or `true`) opts a single run out.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Buk
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,192 @@
1
+ # bundler-conservative-update
2
+
3
+ [![CI](https://github.com/bukhr/bundler-conservative-update/actions/workflows/ci.yml/badge.svg)](https://github.com/bukhr/bundler-conservative-update/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/bundler-conservative-update)](https://rubygems.org/gems/bundler-conservative-update)
5
+
6
+ A [Bundler plugin](https://bundler.io/guides/bundler_plugins.html) that makes
7
+ [`bundle update`](https://bundler.io/man/bundle-update.1.html) conservative by
8
+ default: only the gems you name get updated, and transitive dependencies stay
9
+ locked.
10
+
11
+ ```console
12
+ $ bundle update rails # behaves like `bundle update --conservative rails`
13
+ $ bundle update --all # untouched: explicit "update everything"
14
+ $ bundle update --minor rails # untouched: explicit strategy
15
+ ```
16
+
17
+ ## The problem
18
+
19
+ `bundle update rails` does not just update `rails`. Bundler also unlocks its
20
+ dependencies and moves them to the newest versions the `Gemfile` allows, so a
21
+ one-gem bump produces a `Gemfile.lock` diff nobody asked for or reviewed:
22
+
23
+ ```console
24
+ bundle update rails && git diff --stat Gemfile.lock
25
+ Gemfile.lock | 74 +++++++++++++++++++++++-----------------------
26
+ ```
27
+
28
+ [`--conservative`](https://bundler.io/man/bundle-update.1.html) avoids this, but
29
+ it is opt-in per invocation: it only helps when every person and every script
30
+ remembers to type it, and Bundler has no setting to make it the default
31
+ (`prefer_patch` is related but different, see
32
+ [Relationship to prefer_patch](#relationship-to-prefer_patch)).
33
+
34
+ This plugin turns that behavior into the project's default. Because it is
35
+ declared in the `Gemfile`, the policy covers every `bundle update` run on the
36
+ project — yours, your teammates', and CI's — with no flag to remember.
37
+
38
+ ## Installation
39
+
40
+ Add the plugin to your `Gemfile`:
41
+
42
+ ```ruby
43
+ plugin "bundler-conservative-update"
44
+ ```
45
+
46
+ The next `bundle install` installs the plugin.
47
+
48
+ See [bundle-plugin](https://bundler.io/man/bundle-plugin.1.html).
49
+
50
+ ## Usage
51
+
52
+ There is nothing to configure. Run `bundle update` as usual:
53
+
54
+ ```console
55
+ bundle update rails
56
+ bundler-conservative-update: re-running with --conservative (only requested gems are updated; set BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1 to opt out)
57
+ Pass --patch, --minor, --major or --all to choose a different update strategy.
58
+ ```
59
+
60
+ The plugin re-executes the command as `bundle update --conservative rails`. It
61
+ injects only when the invocation **names something to update** and has not
62
+ already stated its own update strategy:
63
+
64
+ | Invocation | `--conservative` injected? | Why |
65
+ |---|---|---|
66
+ | `bundle update rails` | yes | names a gem |
67
+ | `bundle update rails --local` | yes | flags that do not change resolution scope are preserved |
68
+ | `bundle update --group dev` | yes | Bundler expands the group into the gems to unlock |
69
+ | `bundle update` (no gems) | no | names nothing; see note below |
70
+ | `bundle install`, `bundle exec`, `bundle lock`, … | no | not an update |
71
+ | `bundle update --conservative rails` | no | already requested; also the recursion guard |
72
+ | `bundle update --patch/--minor/--major/--strict rails` | no | explicit update strategy |
73
+ | `bundle update --all` | no | explicit "update everything" |
74
+ | `bundle update --bundler[=X]`, `--source[=X]`, `--ruby` | no | see note below |
75
+ | any of the above with `BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1` | no | [opt-out](#opting-out) |
76
+
77
+ > All the "names nothing" cases share one reason. `--conservative` restricts the
78
+ > resolution through the list of gems Bundler was explicitly asked to unlock;
79
+ > when that list is empty, Bundler falls back to unlocking **every** direct
80
+ > dependency of the `Gemfile` — the exact outcome this plugin exists to prevent.
81
+ > A bare `bundle update` names no gem, and `--bundler`, `--source` and `--ruby`
82
+ > change *what* is being resolved rather than naming gems, so injecting there
83
+ > would not restrict anything. Those invocations are left to Bundler.
84
+
85
+ The re-execution uses `Kernel.exec`, which replaces the current process: stdin
86
+ and the TTY are inherited, and the exit code you get is the real
87
+ `bundle update`'s.
88
+
89
+ ### Opting out
90
+
91
+ To let a single update reach transitive dependencies on purpose, set
92
+ `BUNDLER_CONSERVATIVE_UPDATE_DISABLE` to `1` or `true` (case-insensitive):
93
+
94
+ ```console
95
+ BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1 bundle update rails
96
+ ```
97
+
98
+ There is no permanent, per-project opt-out by design: a project that wants
99
+ unconstrained updates should not declare the plugin. For the periodic "update
100
+ everything" chore no variable is needed — `--all` is already respected:
101
+
102
+ ```console
103
+ bundle update --all
104
+ ```
105
+
106
+ ## Relationship to `prefer_patch`
107
+
108
+ Bundler's [`prefer_patch`](https://bundler.io/man/bundle-config.1.html) setting
109
+ (`BUNDLE_PREFER_PATCH`) makes `bundle update` behave like
110
+ `bundle update --patch`. That controls the **level** of the updates (prefer
111
+ patch releases over minor/major), not their **scope**: shared and transitive
112
+ dependencies can still move.
113
+
114
+ `--conservative` controls the scope: only the gems you named are unlocked,
115
+ everything else stays exactly as locked. This plugin automates the scope part
116
+ only.
117
+
118
+ The two compose. The hook inspects `ARGV`, not Bundler's configuration, so
119
+ `BUNDLE_PREFER_PATCH` does not suppress the injection: with both in place,
120
+ `bundle update rails` updates only `rails` and prefers a patch release. Passing
121
+ `--patch` explicitly on the command line does suppress it, because then the
122
+ invocation already states its own strategy.
123
+
124
+ ## Limitations
125
+
126
+ - **`bundle update` with no gems named is not protected.** The plugin steps
127
+ aside there, so the run behaves like a plain full update. Name the gems you
128
+ want, or pass `--all` to be explicit about it.
129
+ - **`bundle lock --update` is not covered.** It resolves through a code path
130
+ that never fires plugin hooks, so the plugin cannot see the invocation, let
131
+ alone re-execute it. Prefer `bundle update`, and review the `Gemfile.lock`
132
+ diff by hand when you do reach for
133
+ [`bundle lock --update`](https://bundler.io/man/bundle-lock.1.html).
134
+ - **`--bundler`, `--source` and `--ruby` are left unprotected**, for the reason
135
+ described in [Usage](#usage).
136
+ - **Tools that resolve the lockfile without running `bundle update` locally**
137
+ (Dependabot, Renovate, and similar) are not covered.
138
+ - **A fresh clone is unprotected until `bundle install` has run**, since that is
139
+ what installs the plugin.
140
+
141
+ ## How it works
142
+
143
+ [`plugins.rb`](plugins.rb) registers a `before-install-all` hook, which also
144
+ runs for `bundle update`. On that hook,
145
+ [`BundlerConservativeUpdate::Hook`](lib/bundler_conservative_update.rb) inspects
146
+ `ARGV`: it locates the subcommand (the first argument that is not an option,
147
+ skipping the value of options like `--retry 3`), checks that the invocation
148
+ names a gem or a group and carries no explicit strategy, and then re-executes
149
+ `bundle` with `--conservative` inserted right after the subcommand.
150
+
151
+ The decision tables live in
152
+ [`lib/bundler_conservative_update.rb`](lib/bundler_conservative_update.rb) —
153
+ `SKIP_FLAGS`, `SKIP_FLAG_PREFIXES`, `VALUE_FLAGS` and `UPDATE_VALUE_FLAGS`. That
154
+ is the one place to touch when a flag needs to be added.
155
+
156
+ No environment marker is involved. Recursion stops on its own because the
157
+ re-executed command carries `--conservative` in its own `ARGV`, and that flag is
158
+ one of the reasons the hook declines to inject.
159
+
160
+ ## Development
161
+
162
+ ```console
163
+ bundle install
164
+ bundle exec rake test
165
+ ```
166
+
167
+ The suite includes an integration test that installs the plugin into a
168
+ throwaway project and runs a real `bundle update` against it. To run only the
169
+ unit tests, or a single file:
170
+
171
+ ```console
172
+ SKIP_INTEGRATION=1 bundle exec rake test
173
+ bundle exec ruby -Itest test/hook_test.rb
174
+ ```
175
+
176
+ [CI](.github/workflows/ci.yml) runs the full suite on Ruby 3.0 through 3.4.
177
+
178
+ ## Contributing
179
+
180
+ Bug reports and pull requests are welcome at
181
+ [github.com/bukhr/bundler-conservative-update](https://github.com/bukhr/bundler-conservative-update/issues).
182
+ Please include the Bundler version and the exact `bundle update` invocation in
183
+ bug reports, and make sure `bundle exec rake test` passes on pull requests.
184
+
185
+ ## Changelog
186
+
187
+ See [CHANGELOG.md](CHANGELOG.md).
188
+
189
+ ## License
190
+
191
+ The gem is available as open source under the terms of the
192
+ [MIT License](LICENSE.txt).
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundlerConservativeUpdate
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bundler_conservative_update/version"
4
+
5
+ module BundlerConservativeUpdate
6
+ # Flags that suppress the injection, matched exactly.
7
+ #
8
+ # --conservative is both the "the user already asked for it" case and the
9
+ # recursion guard: the re-executed command carries the flag, so the hook
10
+ # declines the second time around.
11
+ #
12
+ # --patch, --minor, --major and --strict are explicit update strategies, and
13
+ # --all is an explicit "update everything" request. In all of them the user
14
+ # has already stated how far the update may reach.
15
+ #
16
+ # --ruby changes what the resolution is about instead of naming gems to
17
+ # update. Combined with an injected --conservative, Bundler's conservative
18
+ # branch finds no explicit gem unlocks and falls back to unlocking every
19
+ # direct dependency, which is the exact outcome this plugin prevents.
20
+ SKIP_FLAGS = %w[--conservative --patch --minor --major --strict --all --ruby].freeze
21
+
22
+ # Flags suppressed by prefix, so `--source foo` and `--source=foo` are both
23
+ # covered. Same reasoning as --ruby: with an injected --conservative they
24
+ # would unlock all direct dependencies.
25
+ SKIP_FLAG_PREFIXES = %w[--bundler --source].freeze
26
+
27
+ # Global options that take a separate value argument. That value must not be
28
+ # mistaken for the subcommand, as in the `3` of `--retry 3`.
29
+ VALUE_FLAGS = %w[--retry -r].freeze
30
+
31
+ # Options of `bundle update` that take a separate value argument. That value
32
+ # must not be mistaken for a gem name, as in the `4` of `--jobs 4`.
33
+ UPDATE_VALUE_FLAGS = %w[--gemfile --jobs -j --cooldown --retry -r].freeze
34
+
35
+ # Escape hatch: set to "1" or "true" to run a genuinely unrestricted
36
+ # `bundle update` when transitive updates are intended.
37
+ DISABLE_ENV = "BUNDLER_CONSERVATIVE_UPDATE_DISABLE"
38
+
39
+ # Decides whether a `bundle update` invocation should be re-executed with
40
+ # --conservative. Instantiated with injected argv and env so it can be tested
41
+ # without a real Bundler run.
42
+ class Hook
43
+ def initialize(argv:, env:)
44
+ @argv = argv
45
+ @env = env
46
+ end
47
+
48
+ # True when the current command is a `bundle update` that names a scope to
49
+ # update and has not already stated its own update strategy.
50
+ def should_inject?
51
+ index = update_index
52
+
53
+ return false if index.nil?
54
+ return false unless @argv[index] == "update"
55
+
56
+ args = @argv[(index + 1)..] || []
57
+
58
+ return false if skip_flag?(args)
59
+ return false unless scope?(args)
60
+ return false if disabled?
61
+
62
+ true
63
+ end
64
+
65
+ # The original argv with --conservative inserted right after the
66
+ # subcommand, leaving the receiver's argv untouched.
67
+ def conservative_argv
68
+ new_argv = @argv.dup
69
+ new_argv.insert(update_index + 1, "--conservative")
70
+ new_argv
71
+ end
72
+
73
+ private
74
+
75
+ # Index of the subcommand: the first argument that is not an option,
76
+ # skipping the value of the global options that take one.
77
+ def update_index
78
+ index = 0
79
+
80
+ while index < @argv.length
81
+ arg = @argv[index]
82
+
83
+ if VALUE_FLAGS.include?(arg)
84
+ index += 2
85
+ next
86
+ end
87
+
88
+ return index unless arg.start_with?("-")
89
+
90
+ index += 1
91
+ end
92
+
93
+ nil
94
+ end
95
+
96
+ # True when the invocation names something to update: gems, or a group
97
+ # whose direct dependencies Bundler expands into the gem list.
98
+ #
99
+ # A bare `bundle update` names nothing. Bundler treats that as a full
100
+ # update and, with a --conservative that carries no explicit unlocks,
101
+ # falls back to unlocking every direct dependency of the Gemfile. The
102
+ # injection would neither restrict the resolution nor match the message the
103
+ # plugin prints, so the invocation is left alone, same as --all.
104
+ def scope?(args)
105
+ gem_names?(args) || group_flag?(args)
106
+ end
107
+
108
+ def gem_names?(args)
109
+ index = 0
110
+
111
+ while index < args.length
112
+ arg = args[index]
113
+
114
+ if UPDATE_VALUE_FLAGS.include?(arg)
115
+ index += 2
116
+ next
117
+ end
118
+
119
+ return true unless arg.start_with?("-")
120
+
121
+ index += 1
122
+ end
123
+
124
+ false
125
+ end
126
+
127
+ # --group is matched by prefix so `--group foo` and `--group=foo` are both
128
+ # covered, plus the -g alias.
129
+ def group_flag?(args)
130
+ args.any? { |arg| arg == "-g" || arg.start_with?("--group") }
131
+ end
132
+
133
+ def skip_flag?(args)
134
+ args.any? do |arg|
135
+ SKIP_FLAGS.include?(arg) || SKIP_FLAG_PREFIXES.any? { |prefix| arg.start_with?(prefix) }
136
+ end
137
+ end
138
+
139
+ def disabled?
140
+ %w[1 true].include?(@env[DISABLE_ENV].to_s.downcase)
141
+ end
142
+ end
143
+ end
data/plugins.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler plugin entrypoint. When a `bundle update` without an explicit update
4
+ # strategy is detected, the process is re-executed with --conservative so only
5
+ # the requested gems are updated and transitive dependencies stay locked.
6
+
7
+ require "bundler_conservative_update"
8
+
9
+ Bundler::Plugin.add_hook("before-install-all") do |_dependencies|
10
+ hook = BundlerConservativeUpdate::Hook.new(argv: ARGV, env: ENV)
11
+
12
+ next unless hook.should_inject?
13
+
14
+ spec = Gem.loaded_specs["bundler"]
15
+
16
+ # Without a loaded bundler spec there is no reliable path to re-execute, so
17
+ # the plugin steps aside and lets the plain update run.
18
+ next if spec.nil?
19
+
20
+ # CLI::Update raises the ui level to warn before this hook runs, so info
21
+ # messages are invisible under --quiet. The headline goes through warn.
22
+ Bundler.ui.warn("bundler-conservative-update: re-running with --conservative (only requested gems are updated; set BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1 to opt out)")
23
+ Bundler.ui.info(" Pass --patch, --minor, --major or --all to choose a different update strategy.")
24
+
25
+ Kernel.exec(Gem.ruby, spec.bin_file("bundle"), *hook.conservative_argv)
26
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundler-conservative-update
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Javier Omar Pacheco Moreno
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.25'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.25'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '13.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '13.0'
40
+ description: Re-executes `bundle update` with --conservative, so only the gems you
41
+ name are updated and transitive dependencies stay locked. Explicit strategies (--patch,
42
+ --minor, --major, --strict), explicit scopes (--all, --source, --bundler, --ruby)
43
+ and an opt-out are respected.
44
+ email:
45
+ - contacto@buk.cl
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files:
49
+ - CHANGELOG.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ files:
53
+ - CHANGELOG.md
54
+ - LICENSE.txt
55
+ - README.md
56
+ - lib/bundler_conservative_update.rb
57
+ - lib/bundler_conservative_update/version.rb
58
+ - plugins.rb
59
+ homepage: https://github.com/bukhr/bundler-conservative-update
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ allowed_push_host: https://rubygems.org
64
+ source_code_uri: https://github.com/bukhr/bundler-conservative-update
65
+ documentation_uri: https://github.com/bukhr/bundler-conservative-update/blob/main/README.md
66
+ changelog_uri: https://github.com/bukhr/bundler-conservative-update/blob/main/CHANGELOG.md
67
+ bug_tracker_uri: https://github.com/bukhr/bundler-conservative-update/issues
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '3.0'
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 4.0.14
84
+ specification_version: 4
85
+ summary: A Bundler plugin that makes `bundle update` conservative by default
86
+ test_files: []