Fix Gemfile rewrites duplicating gem declarations (#482, #548) - #552
Conversation
Assisted-by: Claude Code:claude-opus-5-5
Hosted and vendored modes could leave a Gemfile that Bundler refuses
on every install ("You cannot specify the same gem twice"):
- Hosted mode rewrote only the first of several declarations of a gem
(for example one in each of two `group` blocks), leaving an exact
pin next to the original requirement (#548). It now refuses with
redirect_gem_declared_more_than_once, as vendored mode already did.
- Both modes treated a gem they could not see declared in the root
Gemfile as transitive and appended a second declaration, even when
the lock lists it under DEPENDENCIES because the Gemfile declares it
through eval_gemfile or a loop (#482). Both now refuse instead.
Fixes #482
Fixes #548
Assisted-by: Claude Code:claude-opus-5-5
A direct dependency declared through eval_gemfile must be refused before any write (#482), and Bundler must still install the project frozen and unfrozen. Assisted-by: Claude Code:claude-opus-5-5
Wraps one long assert in the new Bundler capstone so rustfmt leaves the file unchanged. No behavior change. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
Hosted redirect refused a Gemfile with one editable declaration when another gem call quoted the same name, e.g. `gem "other", require: "rack"` or a trailing comment. Only `gem` calls whose first argument is the gem now count toward the more-than-once refusal. The looser probe still gates appending a source block. Assisted-by: Claude Code:claude-opus-5-5
|
BugBot review Generated by Claude Code |
|
[burn-down agent] Labeled Ready for review at
Slack announcement not sent (no Slack send tool available this run). Generated by Claude Code |
|
bugbot run Generated by Claude Code |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit fdbf3af. Configure here.
|
Reviewed Validation: 261 gem unit tests passed on the original head. On the merged code, 11 real-Bundler hosted tests and the vendor |
|
[burn-down agent] Labeled Ready for review at
Generated by Claude Code |
LLM Description written by Claude Code:claude-opus-5-5
Fixes #482
Fixes #548
Root cause
Both Gemfile rewriters decide between "rewrite the declaration" and "append a block" from a regex probe of the root
Gemfiletext alone:patch/redirect/mod.rs,rewrite_gem):gem_line_re.captures(gf)takes only the first literal match. A gem declared twice (for example in twogroupblocks, Hosted gem redirect rewrites only the first of a gem's declarations, so a gem listed in twogroupblocks makes everybundle installfail with "You cannot specify the same gem twice" #548) gets one line rewritten to= x.y.zwhile the other stays>= 0, so Bundler refuses the Gemfile.declared_re/plan_gemfile_edit): when the root text shows no declaration, the gem is treated as transitive and a block is appended. The rewriter never checks whether Bundler sees the gem as a direct dependency througheval_gemfileor a loop (Hosted gem redirect appends a second declaration when the gem is declared througheval_gemfileor a loop, so everybundle installfails with "You cannot specify the same gem twice" #482). It ends up declared twice, and everybundle installexits 4.Fix
formats/gem: the shared lock parser now records everyDEPENDENCIESentry (GemfileLock::direct), exposed aslock_lists_direct_dependency.gemcalls whose first argument is the gem (another gem'srequire: "x"or a comment quoting the name does not count). More than one declaration givesredirect_gem_declared_more_than_once, with nothing written and nothing attested (vendored mode already refused this shape). Before appending, a gem the lock lists as direct givesredirect_gem_declaration_not_visibleinstead.Appendplan for a gem the lock lists as direct is refused asgemfile_declaration_not_editable(refuse_append_of_direct_dependency).Trade-off: a
gemspecdevelopment dependency also appears underDEPENDENCIES, so patching one of those through an appended block is now refused rather than relying on Bundler's "gemspec dependency overridden" warning path. This fails closed, and the warning text names the gemspec case.Test evidence
Red→green: with the fix neutered (
lock_lists_direct_dependencyforced tofalse, duplicate-declaration guard disabled), all three new unit tests fail; with the fix they pass.patch::redirect::tests::gemfile_gem_declared_twice_fails_closed; Bundler capstoneDriver::ScanVexDuplicateDeclarationine2e_redirect_gem_buildpatch::redirect::tests::gemfile_name_quoted_by_another_gem_call_still_rewritespatch::redirect::tests::gemfile_direct_dependency_declared_out_of_sight_is_not_appended,vendor::gem::tests::direct_dependency_declared_out_of_sight_refuses_instead_of_appending; Bundler capstones ine2e_redirect_gem_buildande2e_vendor_gem_buildCommands run locally (Ruby 3.3.6, Bundler 4.0.17):
cargo clippy --workspace --all-features -- -D warnings: cleanrustfmt --checkon every file this PR touches: hunks clean (main has pre-existing unformatted code elsewhere, and CI does not gate on fmt)cargo test --workspace --all-features --no-fail-fast: 208 test binaries ok. 12 failures, all write-permission-failure tests (*_write_failure_*,*unremovable*,relax_loop_must_not_traverse_symlinked_root) that cannot fail as root in this sandbox. None touch gem code.cargo test -p socket-patch-cli --all-features --test e2e_redirect_gem_build -- --ignored: 10 passedcargo test -p socket-patch-cli --all-features --test e2e_vendor_gem_build -- --ignored: 6 passed (re-run on e13a087: both gem build suites green again)e2e_gemneeds the live patch API, which this sandbox cannot reach; left to CI🤖 Generated with Claude Code
https://claude.ai/code/session_01Lkn7GeDBpBAZrZ1sFQuc1y
Note
Medium Risk
Changes hosted redirect and vendored Gemfile planning for gems; behavior is fail-closed with new warnings/errors, but incorrect lock parsing or declaration counting could block legitimate patches.
Overview
Fixes #482 and #548 by making gem hosted redirect and vendored Gemfile edits fail closed instead of leaving Bundler with duplicate or conflicting declarations.
The shared lock parser now tracks every name under
DEPENDENCIES(GemfileLock::direct/lock_lists_direct_dependency). Hosted redirect counts realgemdeclarations whose first argument is the target gem; more than one yieldsredirect_gem_declared_more_than_oncewith no Gemfile/lock writes. If the rewriter cannot see a declaration but the lock lists the gem as direct (eval_gemfile, loops, etc.), it skips withredirect_gem_declaration_not_visibleinstead of appending a source block. Vendored mode refuses anAppendplan in that situation asgemfile_declaration_not_editableviarefuse_append_of_direct_dependency.Unit tests cover duplicate declarations, false-positive guards (
require:/ comments), and out-of-sight direct deps; e2e capstones exercise two-group Gemfiles,eval_gemfile, and vendor refusal on the same layout.Reviewed by Cursor Bugbot for commit fdbf3af. Configure here.
Generated by Claude Code