diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb index 78ab747215ca..7a9c1474cdae 100644 --- a/lib/bundler/source/git/git_proxy.rb +++ b/lib/bundler/source/git/git_proxy.rb @@ -193,7 +193,13 @@ 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 = renamed_remote_default_branch if tracking_remote_default_branch? + if default_branch + out = follow_remote_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) else if shallow? args -= depth_args @@ -298,6 +304,61 @@ def not_pinned? branch_option || ref.nil? end + def tracking_remote_default_branch? + explicit_ref.nil? && commit.nil? + end + + # 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}") + check_allowed(command) + + out, err, status = capture(command, path) + unless status.success? + 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 GitError => e + Bundler.ui.debug "Could not repoint the cached clone at #{reference}: #{e.message}" + return + end + @current_branch = nil + Bundler.ui.warn "#{credential_filtered_uri} no longer has #{previous_branch}, " \ + "now following its default branch #{default_branch}" + out + end + + # 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 + + default_branch + end + + def remote_default_branch + command = ["ls-remote", "--symref", "--", configured_uri, "HEAD"] + check_allowed(command) + + out, err, status = capture(command, path) + unless status.success? + Bundler.ui.debug "Could not ask #{credential_filtered_uri} for its default branch: #{err}" + return + end + + # 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? full_sha_revision?(ref) end @@ -464,8 +525,8 @@ def extra_clone_args args end - def fetch_command(args) - ["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) 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" } diff --git a/spec/update/git_spec.rb b/spec/update/git_spec.rb index 526e988ab7c7..93a858c53ae7 100644 --- a/spec/update/git_spec.rb +++ b/spec/update/git_spec.rb @@ -2,6 +2,11 @@ RSpec.describe "bundle update" do describe "git sources" do + 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 build_git "foo", "1.0" update_git "foo", branch: "omg" @@ -22,6 +27,45 @@ 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 + + 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("no longer has main, now following its default branch renamed") + 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" + expect(cached_head_for("foo-1.0")).to eq("ref: refs/heads/main") + 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|