From ccd31a1eed60b0a8239ea758f1ff0473d10b694a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 10:03:53 +0900 Subject: [PATCH 01/15] Follow renamed default branch in git sources When a git source pins no branch, Bundler fetches whatever branch the cached clone's HEAD points at. Renaming the remote's default branch made that fetch fail, and the failure was swallowed as a network error, so the lockfile stayed silently pinned to the old revision. Resolve the remote's current default branch on that failure and repoint the cached clone. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 43 +++++++++++++++++++++++++++-- spec/update/git_spec.rb | 20 ++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 78ab747215ca..b9499bd04be0 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -193,7 +193,21 @@ def git_remote_fetch(args) return out if status.success? if err.include?("couldn't find remote ref") || err.include?("not our ref") - raise MissingGitRevisionError.new(command_with_no_credentials, path, commit || explicit_ref, credential_filtered_uri) + default_branch = tracking_remote_default_branch? && renamed_remote_default_branch + if default_branch + reference = "refs/heads/#{default_branch}" + command = fetch_command(args, "#{reference}:#{reference}") + command_with_no_credentials = check_allowed(command) + + out, _, status = capture(command, path) + if status.success? + git "symbolic-ref", "HEAD", reference, dir: path + @current_branch = nil + return out + end + end + + raise MissingGitRevisionError.new(command_with_no_credentials, path, commit || explicit_ref || current_branch, credential_filtered_uri) else if shallow? args -= depth_args @@ -298,6 +312,31 @@ def not_pinned? branch_option || ref.nil? end + # True when the source follows the remote's default branch, i.e. no + # branch, tag, ref or locked revision was requested. Only in this case + # can a fetch fail because the remote's default branch was renamed. + def tracking_remote_default_branch? + explicit_ref.nil? && commit.nil? + end + + # When fetching the cached clone's HEAD branch fails, the remote's + # default branch may have been renamed. Ask the remote for its current + # default branch and return its name if it differs from what the cache + # is tracking, so the caller can fetch it and repoint HEAD. + def renamed_remote_default_branch + default_branch = remote_default_branch + return if default_branch.nil? || default_branch == current_branch + + default_branch + end + + def remote_default_branch + out, status = git_null("ls-remote", "--symref", configured_uri, "HEAD", dir: path) + return unless status.success? + + out[%r{^ref:\s+refs/heads/(.+?)\s+HEAD}, 1] + end + def pinned_to_full_sha? full_sha_revision?(ref) end @@ -464,7 +503,7 @@ def extra_clone_args args end - def fetch_command(args) + def fetch_command(args, refspec = refspec()) ["fetch", "--force", "--quiet", "--no-tags", *args, "--", configured_uri, refspec].compact end diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index 526e988ab7c7..e1c1983b78d1 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -22,6 +22,26 @@ expect(the_bundle).to include_gems "foo 1.1" end + it "updates a source with no :branch when its default branch was renamed" do + build_git "foo", "1.0" + + install_gemfile <<-G + source "https://gem.repo1" + gem "foo", :git => "#{lib_path("foo-1.0")}" + G + + # Simulate renaming the remote's default branch (e.g. master -> main) and + # then pushing a new commit onto the renamed branch. + git "branch -m main renamed", lib_path("foo-1.0") + update_git "foo" do |s| + s.write "lib/foo.rb", "FOO = '1.1'" + end + + bundle "update", all: true + + expect(the_bundle).to include_gems "foo 1.1" + end + it "updates correctly when you have like craziness" do build_lib "activesupport", "3.0", path: lib_path("rails/activesupport") build_git "rails", "3.0", path: lib_path("rails") do |s| From b25ab5d12e4d545a983e7b1758a0d8f80ebac074 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 10:08:30 +0900 Subject: [PATCH 02/15] Adjust style in default branch rename handling Rename the branch without naming the old one so the spec does not depend on what `build_git` picks as its default branch. Drop the comment that repeated the rename rationale already stated on the recovery helper. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 10 +++------- spec/update/git_spec.rb | 5 ++--- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index b9499bd04be0..79b1a9723766 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -312,17 +312,13 @@ def not_pinned? branch_option || ref.nil? end - # True when the source follows the remote's default branch, i.e. no - # branch, tag, ref or locked revision was requested. Only in this case - # can a fetch fail because the remote's default branch was renamed. def tracking_remote_default_branch? explicit_ref.nil? && commit.nil? end - # When fetching the cached clone's HEAD branch fails, the remote's - # default branch may have been renamed. Ask the remote for its current - # default branch and return its name if it differs from what the cache - # is tracking, so the caller can fetch it and repoint HEAD. + # Fetching the branch the cached clone's HEAD points at fails once the + # remote renames its default branch, so ask the remote for its current + # one and let the caller fetch that instead. def renamed_remote_default_branch default_branch = remote_default_branch return if default_branch.nil? || default_branch == current_branch diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index e1c1983b78d1..384238e6c26b 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -30,9 +30,8 @@ gem "foo", :git => "#{lib_path("foo-1.0")}" G - # Simulate renaming the remote's default branch (e.g. master -> main) and - # then pushing a new commit onto the renamed branch. - git "branch -m main renamed", lib_path("foo-1.0") + # Rename the remote default branch, then commit onto the renamed branch + git "branch -m renamed", lib_path("foo-1.0") update_git "foo" do |s| s.write "lib/foo.rb", "FOO = '1.1'" end From d5c57688a53952c212fb8729780a292ad1783fdf Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 10:58:06 +0900 Subject: [PATCH 03/15] Keep the default branch recovery out of the retried command The recovery reassigned the `command` and `command_with_no_credentials` locals that the enclosing retry block closes over. When `git symbolic-ref` then failed, the retry re-ran the recovery fetch instead of the original one, that fetch succeeded because the ref was already there, and the block returned success with HEAD still on the deleted branch, so a stale revision was written with nothing reported. The same reassignment made a failed recovery report the recovery command next to the old branch name. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 79b1a9723766..71d2ade3a7c2 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -195,16 +195,8 @@ def git_remote_fetch(args) if err.include?("couldn't find remote ref") || err.include?("not our ref") default_branch = tracking_remote_default_branch? && renamed_remote_default_branch if default_branch - reference = "refs/heads/#{default_branch}" - command = fetch_command(args, "#{reference}:#{reference}") - command_with_no_credentials = check_allowed(command) - - out, _, status = capture(command, path) - if status.success? - git "symbolic-ref", "HEAD", reference, dir: path - @current_branch = nil - return out - end + out = follow_renamed_default_branch(args, default_branch) + return out if out end raise MissingGitRevisionError.new(command_with_no_credentials, path, commit || explicit_ref || current_branch, credential_filtered_uri) @@ -316,6 +308,23 @@ def tracking_remote_default_branch? explicit_ref.nil? && commit.nil? end + # Fetches the remote's new default branch and repoints the cached clone's + # HEAD at it. Returns nil when the fetch fails, leaving HEAD untouched so + # the caller reports the original fetch failure. Runs inside the retry + # block, so it must not touch the caller's command locals. + def follow_renamed_default_branch(args, default_branch) + reference = "refs/heads/#{default_branch}" + command = fetch_command(args, "#{reference}:#{reference}") + check_allowed(command) + + out, _, status = capture(command, path) + return unless status.success? + + git "symbolic-ref", "HEAD", reference, dir: path + @current_branch = nil + out + end + # Fetching the branch the cached clone's HEAD points at fails once the # remote renames its default branch, so ask the remote for its current # one and let the caller fetch that instead. From cc37f633a79ea9fad6e29b0591394d579c4ac8d4 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 10:58:40 +0900 Subject: [PATCH 04/15] Report following a renamed default branch Repointing the cached clone's HEAD changes which branch every project sharing that cache tracks, so it should not happen silently. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 71d2ade3a7c2..d9e3e65885bd 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -320,8 +320,11 @@ def follow_renamed_default_branch(args, default_branch) out, _, status = capture(command, path) return unless status.success? + previous_branch = current_branch git "symbolic-ref", "HEAD", reference, dir: path @current_branch = nil + Bundler.ui.info "The default branch of #{credential_filtered_uri} was renamed " \ + "from #{previous_branch} to #{default_branch}, now following it" out end From aa5e23a0b87e6a26aeca2150867e58296b76c470 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 10:59:06 +0900 Subject: [PATCH 05/15] Terminate options before the URI in the ls-remote call Every other git invocation here puts the Gemfile-controlled URI after `--` so git cannot read it as an option, and `git ls-remote` accepts `--upload-pack`, which runs a command. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index d9e3e65885bd..9fa99ddeacf0 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -339,7 +339,7 @@ def renamed_remote_default_branch end def remote_default_branch - out, status = git_null("ls-remote", "--symref", configured_uri, "HEAD", dir: path) + out, status = git_null("ls-remote", "--symref", "--", configured_uri, "HEAD", dir: path) return unless status.success? out[%r{^ref:\s+refs/heads/(.+?)\s+HEAD}, 1] From 8b6e37b5d4d7a1501b70582f9b380799c7abb8f6 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 11:00:00 +0900 Subject: [PATCH 06/15] Cover that a pinned branch never follows a rename Dropping the guard on the recovery path left every git spec green, so nothing stopped an explicit `:branch` from being silently swapped for whatever branch the remote now points HEAD at. Co-Authored-By: Claude Opus 5 --- spec/update/git_spec.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index 384238e6c26b..c062e6072278 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -41,6 +41,25 @@ expect(the_bundle).to include_gems "foo 1.1" end + it "does not follow a renamed default branch when :branch is used" do + build_git "foo", "1.0" + + install_gemfile <<-G + source "https://gem.repo1" + gem "foo", :git => "#{lib_path("foo-1.0")}", :branch => "main" + G + + git "branch -m renamed", lib_path("foo-1.0") + update_git "foo" do |s| + s.write "lib/foo.rb", "FOO = '1.1'" + end + + bundle "update", all: true + + expect(err).to include("Revision main does not exist") + expect(the_bundle).to include_gems "foo 1.0" + end + it "updates correctly when you have like craziness" do build_lib "activesupport", "3.0", path: lib_path("rails/activesupport") build_git "rails", "3.0", path: lib_path("rails") do |s| From e305d478f4ced95d47a801d88cf6e9eac993f068 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 11:01:48 +0900 Subject: [PATCH 07/15] Record why a default branch lookup came back empty `git_null` drops stderr, so an unsupported `--symref`, an auth failure and a network error all collapsed into the same silent nil and the recovery just did not happen with nothing to explain it. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 9fa99ddeacf0..00ff71c5d32d 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -339,8 +339,14 @@ def renamed_remote_default_branch end def remote_default_branch - out, status = git_null("ls-remote", "--symref", "--", configured_uri, "HEAD", dir: path) - return unless status.success? + command = ["ls-remote", "--symref", "--", configured_uri, "HEAD"] + command_with_no_credentials = check_allowed(command) + + out, err, status = capture(command, path) + unless status.success? + Bundler.ui.debug "`#{command_with_no_credentials}` failed, cannot tell whether the default branch was renamed: #{err}" + return + end out[%r{^ref:\s+refs/heads/(.+?)\s+HEAD}, 1] end From 17ff88d7560f2d1f7bb25fbd8cdd655fde2eba56 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 11:03:04 +0900 Subject: [PATCH 08/15] Name the fetch refspec parameter apart from its default `refspec = refspec()` only reaches the method because of the parentheses. Dropping them binds the half-defined local instead, and `.compact` then turns the missing refspec into a fetch that quietly updates nothing. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 00ff71c5d32d..22fd30e86710 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -517,8 +517,8 @@ def extra_clone_args args end - def fetch_command(args, refspec = refspec()) - ["fetch", "--force", "--quiet", "--no-tags", *args, "--", configured_uri, refspec].compact + def fetch_command(args, spec = refspec) + ["fetch", "--force", "--quiet", "--no-tags", *args, "--", configured_uri, spec].compact end def clone_command(args) From f13713e77f4e66e18e9eefec0b2846bc313e94d1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 11:04:14 +0900 Subject: [PATCH 09/15] Bind the default branch to nil rather than false The `&&` form put a boolean and a branch name in the same local, so any later `nil?` check would have missed the false case. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 22fd30e86710..865d7b5a906c 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -193,7 +193,7 @@ def git_remote_fetch(args) return out if status.success? if err.include?("couldn't find remote ref") || err.include?("not our ref") - default_branch = tracking_remote_default_branch? && renamed_remote_default_branch + default_branch = renamed_remote_default_branch if tracking_remote_default_branch? if default_branch out = follow_renamed_default_branch(args, default_branch) return out if out From 74c5343c2c9172d34ca91f9bfc71ea68e32c3382 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 12:21:38 +0900 Subject: [PATCH 10/15] Give up quietly when the cached HEAD cannot be moved A failed `git symbolic-ref` raised through the retry block, which then redid the whole recovery on each attempt: four passes, twelve network round trips and nine seconds of backoff for a local lock conflict. Treat it as best effort instead and report the original fetch failure, which also stops the cache being left with the new branch fetched but HEAD still on the old one. The recovery fetch now says why it failed too. The message it prints on success no longer claims the branch was renamed, which it cannot observe. A deleted branch, a cache seeded by another project's `branch:`, and a detached cache HEAD all reach the same line and none of them is a rename. It also warns rather than informs now, since it changes which commit gets installed and `--quiet` drops info. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 30 ++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 865d7b5a906c..c49ecb4d2d59 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -195,7 +195,7 @@ def git_remote_fetch(args) if err.include?("couldn't find remote ref") || err.include?("not our ref") default_branch = renamed_remote_default_branch if tracking_remote_default_branch? if default_branch - out = follow_renamed_default_branch(args, default_branch) + out = follow_remote_default_branch(args, default_branch) return out if out end @@ -308,23 +308,31 @@ def tracking_remote_default_branch? explicit_ref.nil? && commit.nil? end - # Fetches the remote's new default branch and repoints the cached clone's - # HEAD at it. Returns nil when the fetch fails, leaving HEAD untouched so - # the caller reports the original fetch failure. Runs inside the retry + # Fetches the branch the remote now points HEAD at and repoints the cached + # clone's HEAD to match. Returns nil on any failure, leaving HEAD untouched + # so the caller reports the original fetch failure. Runs inside the retry # block, so it must not touch the caller's command locals. - def follow_renamed_default_branch(args, default_branch) + def follow_remote_default_branch(args, default_branch) reference = "refs/heads/#{default_branch}" command = fetch_command(args, "#{reference}:#{reference}") - check_allowed(command) + command_with_no_credentials = check_allowed(command) - out, _, status = capture(command, path) - return unless status.success? + out, err, status = capture(command, path) + unless status.success? + Bundler.ui.debug "`#{command_with_no_credentials}` failed, cannot follow #{default_branch}: #{err}" + return + end previous_branch = current_branch - git "symbolic-ref", "HEAD", reference, dir: path + begin + git "symbolic-ref", "HEAD", reference, dir: path + rescue GitCommandError => e + Bundler.ui.debug "Could not repoint the cached clone at #{reference}: #{e.message}" + return + end @current_branch = nil - Bundler.ui.info "The default branch of #{credential_filtered_uri} was renamed " \ - "from #{previous_branch} to #{default_branch}, now following it" + Bundler.ui.warn "#{credential_filtered_uri} no longer has #{previous_branch}, " \ + "now following its default branch #{default_branch}" out end From d68dbc6bfd82e01aca7419c6f273d39e8e49f979 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 12:25:15 +0900 Subject: [PATCH 11/15] Pin the recovery's visible effects in the git specs Deleting the warning or the guard that keeps a pinned branch from following left both specs green, so neither the message nor the cached clone's HEAD was actually covered. Co-Authored-By: Claude Opus 5 --- spec/update/git_spec.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index c062e6072278..1c32e4c4db7f 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -2,6 +2,10 @@ RSpec.describe "bundle update" do describe "git sources" do + def bare_cache_for(name) + Dir.glob(default_cache_path("git/#{name}-*")).first + end + it "floats on a branch when :branch is used" do build_git "foo", "1.0" update_git "foo", branch: "omg" @@ -38,6 +42,7 @@ bundle "update", all: true + expect(err).to include("no longer has main, now following its default branch renamed") expect(the_bundle).to include_gems "foo 1.1" end @@ -58,6 +63,7 @@ expect(err).to include("Revision main does not exist") expect(the_bundle).to include_gems "foo 1.0" + expect(git("symbolic-ref HEAD", bare_cache_for("foo-1.0")).strip).to eq("refs/heads/main") end it "updates correctly when you have like craziness" do From 33f8cd69ce694dcf59fcbd1f62f33b8f47819ef3 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 12:26:56 +0900 Subject: [PATCH 12/15] Trim the comments back to what the code does not say The added block ran to about 10% comment lines against roughly 4% for the file, and part of it narrated the method body instead of explaining it. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 12 +++++------- spec/update/git_spec.rb | 1 - 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index c49ecb4d2d59..11c5b1074146 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -308,10 +308,9 @@ def tracking_remote_default_branch? explicit_ref.nil? && commit.nil? end - # Fetches the branch the remote now points HEAD at and repoints the cached - # clone's HEAD to match. Returns nil on any failure, leaving HEAD untouched - # so the caller reports the original fetch failure. Runs inside the retry - # block, so it must not touch the caller's command locals. + # Returns nil on any failure, leaving HEAD untouched so the caller reports + # the original fetch failure. Runs inside the retry block, so it must not + # touch the caller's command locals. def follow_remote_default_branch(args, default_branch) reference = "refs/heads/#{default_branch}" command = fetch_command(args, "#{reference}:#{reference}") @@ -336,9 +335,8 @@ def follow_remote_default_branch(args, default_branch) out end - # Fetching the branch the cached clone's HEAD points at fails once the - # remote renames its default branch, so ask the remote for its current - # one and let the caller fetch that instead. + # The cached clone's HEAD branch is gone from the remote, so the remote's + # own idea of its default branch is the only thing left to follow. def renamed_remote_default_branch default_branch = remote_default_branch return if default_branch.nil? || default_branch == current_branch diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index 1c32e4c4db7f..c9c2ac6b38b9 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -34,7 +34,6 @@ def bare_cache_for(name) gem "foo", :git => "#{lib_path("foo-1.0")}" G - # Rename the remote default branch, then commit onto the renamed branch git "branch -m renamed", lib_path("foo-1.0") update_git "foo" do |s| s.write "lib/foo.rb", "FOO = '1.1'" From aeb113a6f5e8beb489faa908c91ec245e792df05 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 14:00:42 +0900 Subject: [PATCH 13/15] Keep the default branch lookup inside the GitError family A remote may advertise a ref name that is not valid UTF-8, and matching that as text raises ArgumentError, which is not a GitError, so it was retried four times and then escaped as a backtrace. Match the bytes instead. The rescue around the repoint was narrower than the comment above it claimed, and the debug lines carried the whole command, which still holds credentials that the redaction does not reach. Co-Authored-By: Claude Opus 5 --- lib/bundler/source/git/git_proxy.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 11c5b1074146..7a9c1474cdae 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -314,18 +314,18 @@ def tracking_remote_default_branch? def follow_remote_default_branch(args, default_branch) reference = "refs/heads/#{default_branch}" command = fetch_command(args, "#{reference}:#{reference}") - command_with_no_credentials = check_allowed(command) + check_allowed(command) out, err, status = capture(command, path) unless status.success? - Bundler.ui.debug "`#{command_with_no_credentials}` failed, cannot follow #{default_branch}: #{err}" + Bundler.ui.debug "Could not fetch #{reference} from #{credential_filtered_uri}: #{err}" return end previous_branch = current_branch begin git "symbolic-ref", "HEAD", reference, dir: path - rescue GitCommandError => e + rescue GitError => e Bundler.ui.debug "Could not repoint the cached clone at #{reference}: #{e.message}" return end @@ -346,15 +346,17 @@ def renamed_remote_default_branch def remote_default_branch command = ["ls-remote", "--symref", "--", configured_uri, "HEAD"] - command_with_no_credentials = check_allowed(command) + check_allowed(command) out, err, status = capture(command, path) unless status.success? - Bundler.ui.debug "`#{command_with_no_credentials}` failed, cannot tell whether the default branch was renamed: #{err}" + Bundler.ui.debug "Could not ask #{credential_filtered_uri} for its default branch: #{err}" return end - out[%r{^ref:\s+refs/heads/(.+?)\s+HEAD}, 1] + # A remote is free to advertise a ref name that is not valid UTF-8, and + # matching that as text raises out of the GitError family. + out.b[%r{^ref:\s+refs/heads/(.+?)\s+HEAD}, 1] end def pinned_to_full_sha? From cc6293dec7d486a8d64bfb5b2c5dacda4d002b15 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 14:03:06 +0900 Subject: [PATCH 14/15] Read the cached HEAD without shelling out The glob returned nil when it matched nothing and the subprocess helper skips chdir for a nil directory, so the assertion would have run git against the rubygems checkout itself. Deriving the path the way the source does makes a miss an outright failure, and reading the file keeps the assertion from displacing the bundle command that `err` reports on. Co-Authored-By: Claude Opus 5 --- spec/update/git_spec.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index c9c2ac6b38b9..93a858c53ae7 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -2,8 +2,9 @@ RSpec.describe "bundle update" do describe "git sources" do - def bare_cache_for(name) - Dir.glob(default_cache_path("git/#{name}-*")).first + def cached_head_for(name) + path = default_cache_path("git/#{name}-#{Digest(:SHA1).hexdigest(lib_path(name).to_s)}") + File.read(path.join("HEAD")).strip end it "floats on a branch when :branch is used" do @@ -62,7 +63,7 @@ def bare_cache_for(name) expect(err).to include("Revision main does not exist") expect(the_bundle).to include_gems "foo 1.0" - expect(git("symbolic-ref HEAD", bare_cache_for("foo-1.0")).strip).to eq("refs/heads/main") + expect(cached_head_for("foo-1.0")).to eq("ref: refs/heads/main") end it "updates correctly when you have like craziness" do From 7f8eee30adb2778e9d158b2fdb92d8f72f8ea9c9 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Wed, 26 Aug 2026 14:05:13 +0900 Subject: [PATCH 15/15] Unit test the guard that keeps a locked revision pinned Dropping `commit.nil?` from the guard left every spec green, so nothing stopped a source whose locked revision the remote had dropped from silently repointing the shared cache at the default branch instead. Co-Authored-By: Claude Opus 5 --- spec/bundler/source/git/git_proxy_spec.rb | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/spec/bundler/source/git/git_proxy_spec.rb b/spec/bundler/source/git/git_proxy_spec.rb index e2d3bbb6e7ef..4bcc129f9aae 100644 --- a/spec/bundler/source/git/git_proxy_spec.rb +++ b/spec/bundler/source/git/git_proxy_spec.rb @@ -376,6 +376,36 @@ end end + context "when the remote no longer has the branch HEAD points at" do + let(:cached_branch) { "main" } + let(:missing_ref) { ["", "fatal: couldn't find remote ref refs/heads/#{cached_branch}", fail_result] } + let(:symref_advertisement) { ["ref: refs/heads/renamed\tHEAD\n", "", clone_result] } + + before do + allow(git_proxy).to receive(:git_local).with("--version").and_return("git version 2.14.0") + allow(git_proxy).to receive(:git_local).with("rev-parse", "--abbrev-ref", "HEAD", dir: path).and_return(cached_branch) + allow(git_proxy).to receive(:capture).with([*base_fetch_args, "--", uri, "refs/heads/#{cached_branch}:refs/heads/#{cached_branch}"], path).and_return(missing_ref) + end + + it "follows the branch the remote now points HEAD at" do + expect(git_proxy).to receive(:capture).with(["ls-remote", "--symref", "--", uri, "HEAD"], path).and_return(symref_advertisement) + expect(git_proxy).to receive(:capture).with([*base_fetch_args, "--", uri, "refs/heads/renamed:refs/heads/renamed"], path).and_return(["", "", clone_result]) + expect(git_proxy).to receive(:git).with("symbolic-ref", "HEAD", "refs/heads/renamed", dir: path) + subject.checkout + end + + context "and a revision is locked" do + let(:revision) { Digest::SHA1.hexdigest("ruby") } + + it "does not ask the remote for its default branch" do + expect(git_proxy).to receive(:git).with("cat-file", "-e", revision, dir: path).and_raise(Bundler::GitError) + expect(git_proxy).to receive(:capture).with([*base_fetch_args, "--", uri, "#{revision}:refs/#{revision}-sha"], path).and_return(missing_ref) + expect(git_proxy).not_to receive(:capture).with(["ls-remote", "--symref", "--", uri, "HEAD"], path) + expect { subject.checkout }.to raise_error(Bundler::Source::Git::MissingGitRevisionError) + end + end + end + context "URI is HTTP" do let(:uri) { "http://github.com/ruby/rubygems.git" }