bundler-conservative-update
A Bundler plugin that makes
bundle update conservative by
default: only the gems you name get updated, and transitive dependencies stay
locked.
$ bundle update rails # behaves like `bundle update --conservative rails`
$ bundle update --all # untouched: explicit "update everything"
$ bundle update --minor rails # untouched: explicit strategy
The problem
bundle update rails does not just update rails. Bundler also unlocks its
dependencies and moves them to the newest versions the Gemfile allows, so a
one-gem bump produces a Gemfile.lock diff nobody asked for or reviewed:
bundle update rails && git diff --stat Gemfile.lock
Gemfile.lock | 74 +++++++++++++++++++++++-----------------------
--conservative avoids this, but
it is opt-in per invocation: it only helps when every person and every script
remembers to type it, and Bundler has no setting to make it the default
(prefer_patch is related but different, see
Relationship to prefer_patch).
This plugin turns that behavior into the project's default. Because it is
declared in the Gemfile, the policy covers every bundle update run on the
project — yours, your teammates', and CI's — with no flag to remember.
Installation
Add the plugin to your Gemfile:
plugin "bundler-conservative-update"
The next bundle install installs the plugin.
See bundle-plugin.
Usage
There is nothing to configure. Run bundle update as usual:
bundle update rails
bundler-conservative-update: re-running with --conservative (only requested gems are updated; set BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1 to opt out)
Pass --patch, --minor, --major or --all to choose a different update strategy.
The plugin re-executes the command as bundle update --conservative rails. It
injects only when the invocation names something to update and has not
already stated its own update strategy:
| Invocation | --conservative injected? |
Why |
|---|---|---|
bundle update rails |
yes | names a gem |
bundle update rails --local |
yes | flags that do not change resolution scope are preserved |
bundle update --group dev |
yes | Bundler expands the group into the gems to unlock |
bundle update (no gems) |
no | names nothing; see note below |
bundle install, bundle exec, bundle lock, … |
no | not an update |
bundle update --conservative rails |
no | already requested; also the recursion guard |
bundle update --patch/--minor/--major/--strict rails |
no | explicit update strategy |
bundle update --all |
no | explicit "update everything" |
bundle update --bundler[=X], --source[=X], --ruby |
no | see note below |
any of the above with BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1 |
no | opt-out |
All the "names nothing" cases share one reason.
--conservativerestricts the resolution through the list of gems Bundler was explicitly asked to unlock; when that list is empty, Bundler falls back to unlocking every direct dependency of theGemfile— the exact outcome this plugin exists to prevent. A barebundle updatenames no gem, and--bundler,--sourceand--rubychange what is being resolved rather than naming gems, so injecting there would not restrict anything. Those invocations are left to Bundler.
The re-execution uses Kernel.exec, which replaces the current process: stdin
and the TTY are inherited, and the exit code you get is the real
bundle update's.
Opting out
To let a single update reach transitive dependencies on purpose, set
BUNDLER_CONSERVATIVE_UPDATE_DISABLE to 1 or true (case-insensitive):
BUNDLER_CONSERVATIVE_UPDATE_DISABLE=1 bundle update rails
There is no permanent, per-project opt-out by design: a project that wants
unconstrained updates should not declare the plugin. For the periodic "update
everything" chore no variable is needed — --all is already respected:
bundle update --all
Relationship to prefer_patch
Bundler's prefer_patch setting
(BUNDLE_PREFER_PATCH) makes bundle update behave like
bundle update --patch. That controls the level of the updates (prefer
patch releases over minor/major), not their scope: shared and transitive
dependencies can still move.
--conservative controls the scope: only the gems you named are unlocked,
everything else stays exactly as locked. This plugin automates the scope part
only.
The two compose. The hook inspects ARGV, not Bundler's configuration, so
BUNDLE_PREFER_PATCH does not suppress the injection: with both in place,
bundle update rails updates only rails and prefers a patch release. Passing
--patch explicitly on the command line does suppress it, because then the
invocation already states its own strategy.
Limitations
bundle updatewith no gems named is not protected. The plugin steps aside there, so the run behaves like a plain full update. Name the gems you want, or pass--allto be explicit about it.bundle lock --updateis not covered. It resolves through a code path that never fires plugin hooks, so the plugin cannot see the invocation, let alone re-execute it. Preferbundle update, and review theGemfile.lockdiff by hand when you do reach forbundle lock --update.--bundler,--sourceand--rubyare left unprotected, for the reason described in Usage.- Tools that resolve the lockfile without running
bundle updatelocally (Dependabot, Renovate, and similar) are not covered. - A fresh clone is unprotected until
bundle installhas run, since that is what installs the plugin.
How it works
plugins.rb registers a before-install-all hook, which also
runs for bundle update. On that hook,
BundlerConservativeUpdate::Hook inspects
ARGV: it locates the subcommand (the first argument that is not an option,
skipping the value of options like --retry 3), checks that the invocation
names a gem or a group and carries no explicit strategy, and then re-executes
bundle with --conservative inserted right after the subcommand.
The decision tables live in
lib/bundler_conservative_update.rb —
SKIP_FLAGS, SKIP_FLAG_PREFIXES, VALUE_FLAGS and UPDATE_VALUE_FLAGS. That
is the one place to touch when a flag needs to be added.
No environment marker is involved. Recursion stops on its own because the
re-executed command carries --conservative in its own ARGV, and that flag is
one of the reasons the hook declines to inject.
Development
bundle install
bundle exec rake test
The suite includes an integration test that installs the plugin into a
throwaway project and runs a real bundle update against it. To run only the
unit tests, or a single file:
SKIP_INTEGRATION=1 bundle exec rake test
bundle exec ruby -Itest test/hook_test.rb
CI runs the full suite on Ruby 3.0 through 3.4.
Contributing
Bug reports and pull requests are welcome at
github.com/bukhr/bundler-conservative-update.
Please include the Bundler version and the exact bundle update invocation in
bug reports, and make sure bundle exec rake test passes on pull requests.
Changelog
See CHANGELOG.md.
License
The gem is available as open source under the terms of the MIT License.