Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions lib/bundler/source/git/git_proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions spec/bundler/source/git/git_proxy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand Down
44 changes: 44 additions & 0 deletions spec/update/git_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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|
Expand Down
Loading