summaryrefslogtreecommitdiff
path: root/lib/bundler/source/git/git_proxy.rb
diff options
context:
space:
mode:
Diffstat (limited to 'lib/bundler/source/git/git_proxy.rb')
-rw-r--r--lib/bundler/source/git/git_proxy.rb458
1 files changed, 330 insertions, 128 deletions
diff --git a/lib/bundler/source/git/git_proxy.rb b/lib/bundler/source/git/git_proxy.rb
index 3db31f0237..cd352c22a7 100644
--- a/lib/bundler/source/git/git_proxy.rb
+++ b/lib/bundler/source/git/git_proxy.rb
@@ -1,7 +1,5 @@
# frozen_string_literal: true
-require "shellwords"
-require "tempfile"
module Bundler
class Source
class Git
@@ -17,26 +15,37 @@ module Bundler
class GitNotAllowedError < GitError
def initialize(command)
msg = String.new
- msg << "Bundler is trying to run a `git #{command}` at runtime. You probably need to run `bundle install`. However, "
- msg << "this error message could probably be more useful. Please submit a ticket at http://github.com/bundler/bundler/issues "
+ msg << "Bundler is trying to run `#{command}` at runtime. You probably need to run `bundle install`. However, "
+ msg << "this error message could probably be more useful. Please submit a ticket at https://github.com/ruby/rubygems/issues/new?labels=Bundler&template=bundler-related-issue.md "
msg << "with steps to reproduce as well as the following\n\nCALLER: #{caller.join("\n")}"
super msg
end
end
class GitCommandError < GitError
- def initialize(command, path = nil, extra_info = nil)
- msg = String.new
- msg << "Git error: command `git #{command}` in directory #{SharedHelpers.pwd} has failed."
+ attr_reader :command
+
+ def initialize(command, path, extra_info = nil)
+ @command = command
+
+ msg = String.new("Git error: command `#{command}`")
+ msg << " in directory #{path}" if path
+ msg << " has failed."
msg << "\n#{extra_info}" if extra_info
- msg << "\nIf this error persists you could try removing the cache directory '#{path}'" if path && path.exist?
super msg
end
end
- class MissingGitRevisionError < GitError
- def initialize(ref, repo)
+ class MissingGitRevisionError < GitCommandError
+ def initialize(command, destination_path, ref, repo)
msg = "Revision #{ref} does not exist in the repository #{repo}. Maybe you misspelled it?"
+ super command, destination_path, msg
+ end
+ end
+
+ class AmbiguousGitReference < GitError
+ def initialize(options)
+ msg = "Specification of branch or ref with tag is ambiguous. You specified #{options.inspect}"
super msg
end
end
@@ -45,72 +54,64 @@ module Bundler
# All actions required by the Git source is encapsulated in this
# object.
class GitProxy
- attr_accessor :path, :uri, :ref
+ attr_accessor :path, :uri, :branch, :tag, :ref, :explicit_ref
attr_writer :revision
- def initialize(path, uri, ref, revision = nil, git = nil)
+ def initialize(path, uri, options = {}, revision = nil, git = nil)
@path = path
@uri = uri
- @ref = ref
+ @tag = options["tag"]
+ @branch = options["branch"]
+ @ref = options["ref"]
+ if @tag
+ raise AmbiguousGitReference.new(options) if @branch || @ref
+ @explicit_ref = @tag
+ else
+ @explicit_ref = @ref || @branch
+ end
@revision = revision
@git = git
- raise GitNotInstalledError.new if allow? && !Bundler.git_present?
+ @commit_ref = nil
end
def revision
- return @revision if @revision
-
- begin
- @revision ||= find_local_revision
- rescue GitCommandError
- raise MissingGitRevisionError.new(ref, URICredentialsFilter.credential_filtered_uri(uri))
- end
-
- @revision
+ @revision ||= allowed_with_path { find_local_revision }
end
- def branch
- @branch ||= allowed_in_path do
- git("rev-parse --abbrev-ref HEAD").strip
+ def current_branch
+ @current_branch ||= with_path do
+ git_local("rev-parse", "--abbrev-ref", "HEAD", dir: path).strip
end
end
def contains?(commit)
- allowed_in_path do
- result = git_null("branch --contains #{commit}")
- $? == 0 && result =~ /^\* (.*)$/
+ allowed_with_path do
+ result, status = git_null("branch", "--contains", commit, dir: path)
+ status.success? && result.match?(/^\* (.*)$/)
end
end
def version
- git("--version").match(/(git version\s*)?((\.?\d+)+).*/)[2]
+ @version ||= full_version.match(/((\.?\d+)+).*/)[1]
end
def full_version
- git("--version").sub("git version", "").strip
+ @full_version ||= git_local("--version").sub(/git version\s*/, "").strip
end
def checkout
- return if path.exist? && has_revision_cached?
- extra_ref = "#{Shellwords.shellescape(ref)}:#{Shellwords.shellescape(ref)}" if ref && ref.start_with?("refs/")
+ return if has_revision_cached?
- Bundler.ui.info "Fetching #{URICredentialsFilter.credential_filtered_uri(uri)}"
+ Bundler.ui.info "Fetching #{credential_filtered_uri}"
- unless path.exist?
- SharedHelpers.filesystem_access(path.dirname) do |p|
- FileUtils.mkdir_p(p)
- end
- git_retry %(clone #{uri_escaped_with_configured_credentials} "#{path}" --bare --no-hardlinks --quiet)
- return unless extra_ref
- end
+ extra_fetch_needed = clone_needs_extra_fetch?
+ unshallow_needed = clone_needs_unshallow?
+ return unless extra_fetch_needed || unshallow_needed
- in_path do
- git_retry %(fetch --force --quiet --tags #{uri_escaped_with_configured_credentials} "refs/heads/*:refs/heads/*" #{extra_ref})
- end
+ git_remote_fetch(unshallow_needed ? ["--unshallow"] : depth_args)
end
def copy_to(destination, submodules = false)
- # method 1
unless File.exist?(destination.join(".git"))
begin
SharedHelpers.filesystem_access(destination.dirname) do |p|
@@ -119,142 +120,343 @@ module Bundler
SharedHelpers.filesystem_access(destination) do |p|
FileUtils.rm_rf(p)
end
- git_retry %(clone --no-checkout --quiet "#{path}" "#{destination}")
- File.chmod(((File.stat(destination).mode | 0o777) & ~File.umask), destination)
+ git "clone", "--no-checkout", "--quiet", path.to_s, destination.to_s
+ File.chmod((File.stat(destination).mode | 0o777) & ~File.umask, destination)
rescue Errno::EEXIST => e
- file_path = e.message[%r{.*?(/.*)}, 1]
+ file_path = e.message[%r{.*?((?:[a-zA-Z]:)?/.*)}, 1]
raise GitError, "Bundler could not install a gem because it needs to " \
"create a directory, but a file exists - #{file_path}. Please delete " \
"this file and try again."
end
end
- # method 2
- SharedHelpers.chdir(destination) do
- git_retry %(fetch --force --quiet --tags "#{path}")
- begin
- git "reset --hard #{@revision}"
- rescue GitCommandError
- raise MissingGitRevisionError.new(@revision, URICredentialsFilter.credential_filtered_uri(uri))
+ ref = @commit_ref || (locked_to_full_sha? && @revision)
+ if ref
+ git "config", "uploadpack.allowAnySHA1InWant", "true", dir: path.to_s if @commit_ref.nil? && needs_allow_any_sha1_in_want?
+
+ git "fetch", "--force", "--quiet", *extra_fetch_args(ref), dir: destination
+ end
+
+ git "reset", "--hard", @revision, dir: destination
+
+ if submodules
+ git_retry "submodule", "update", "--init", "--recursive", dir: destination
+ elsif Gem::Version.create(version) >= Gem::Version.create("2.9.0")
+ inner_command = "git -C $toplevel submodule deinit --force $sm_path"
+ git_retry "submodule", "foreach", "--quiet", inner_command, dir: destination
+ end
+ end
+
+ def installed_to?(destination)
+ # if copy_to is interrupted, it may leave a partially installed directory that
+ # contains .git but no other files -- consider this not to be installed
+ Dir.exist?(destination) && (Dir.children(destination) - [".git"]).any?
+ end
+
+ private
+
+ def git_remote_fetch(args)
+ command = ["fetch", "--force", "--quiet", "--no-tags", *args, "--", configured_uri, refspec].compact
+ command_with_no_credentials = check_allowed(command)
+
+ Bundler::Retry.new("`#{command_with_no_credentials}` at #{path}", [MissingGitRevisionError]).attempts do
+ out, err, status = capture(command, path)
+ 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)
+ else
+ raise GitCommandError.new(command_with_no_credentials, path, err)
end
+ end
+ end
+
+ def clone_needs_extra_fetch?
+ return true if path.exist?
- if submodules
- git_retry "submodule update --init --recursive"
- elsif Gem::Version.create(version) >= Gem::Version.create("2.9.0")
- git_retry "submodule deinit --all --force"
+ SharedHelpers.filesystem_access(path.dirname) do |p|
+ FileUtils.mkdir_p(p)
+ end
+
+ command = ["clone", "--bare", "--no-hardlinks", "--quiet", *extra_clone_args, "--", configured_uri, path.to_s]
+ command_with_no_credentials = check_allowed(command)
+
+ Bundler::Retry.new("`#{command_with_no_credentials}`", [MissingGitRevisionError]).attempts do
+ _, err, status = capture(command, nil)
+ return extra_ref if status.success?
+
+ if err.include?("Could not find remote branch") || # git up to 2.49
+ err.include?("Remote branch #{branch_option} not found") # git 2.49 or higher
+ raise MissingGitRevisionError.new(command_with_no_credentials, nil, explicit_ref, credential_filtered_uri)
+ else
+ idx = command.index("--depth")
+ if idx
+ command.delete_at(idx)
+ command.delete_at(idx)
+ command_with_no_credentials = check_allowed(command)
+
+ err += "Retrying without --depth argument."
+ end
+ raise GitCommandError.new(command_with_no_credentials, path, err)
end
end
end
- private
+ def clone_needs_unshallow?
+ return false unless path.join("shallow").exist?
+ return true if full_clone?
- # TODO: Do not rely on /dev/null.
- # Given that open3 is not cross platform until Ruby 1.9.3,
- # the best solution is to pipe to /dev/null if it exists.
- # If it doesn't, everything will work fine, but the user
- # will get the $stderr messages as well.
- def git_null(command)
- git("#{command} 2>#{Bundler::NULL}", false)
+ @revision && @revision != head_revision
end
- def git_retry(command)
- Bundler::Retry.new("`git #{URICredentialsFilter.credential_filtered_string(command, uri)}`", GitNotAllowedError).attempts do
- git(command)
+ def extra_ref
+ return false if not_pinned?
+ return true unless full_clone?
+
+ ref.start_with?("refs/")
+ end
+
+ def depth
+ return @depth if defined?(@depth)
+
+ @depth = if !supports_fetching_unreachable_refs?
+ nil
+ elsif not_pinned? || pinned_to_full_sha?
+ 1
+ elsif ref.include?("~")
+ parsed_depth = ref.split("~").last
+ parsed_depth.to_i + 1
end
end
- def git(command, check_errors = true, error_msg = nil)
- command_with_no_credentials = URICredentialsFilter.credential_filtered_string(command, uri)
- raise GitNotAllowedError.new(command_with_no_credentials) unless allow?
+ def refspec
+ if commit
+ @commit_ref = "refs/#{commit}-sha"
+ return "#{commit}:#{@commit_ref}"
+ end
+
+ reference = fully_qualified_ref
+
+ reference ||= if ref.include?("~")
+ ref.split("~").first
+ elsif ref.start_with?("refs/")
+ ref
+ else
+ "refs/*"
+ end
+
+ "#{reference}:#{reference}"
+ end
+
+ def commit
+ @commit ||= pinned_to_full_sha? ? ref : @revision
+ end
+
+ def fully_qualified_ref
+ if branch
+ "refs/heads/#{branch}"
+ elsif tag
+ "refs/tags/#{tag}"
+ elsif ref.nil?
+ "refs/heads/#{current_branch}"
+ end
+ end
+
+ def not_pinned?
+ branch_option || ref.nil?
+ end
+
+ def pinned_to_full_sha?
+ full_sha_revision?(ref)
+ end
+
+ def locked_to_full_sha?
+ full_sha_revision?(@revision)
+ end
+
+ def full_sha_revision?(ref)
+ ref&.match?(/\A\h{40}\z/)
+ end
+
+ def git_null(*command, dir: nil)
+ check_allowed(command)
+
+ capture(command, dir, ignore_err: true)
+ end
- out = SharedHelpers.with_clean_git_env do
- capture_and_filter_stderr(uri) { `git #{command}` }
+ def git_retry(*command, dir: nil)
+ command_with_no_credentials = check_allowed(command)
+
+ Bundler::Retry.new("`#{command_with_no_credentials}` at #{dir || SharedHelpers.pwd}").attempts do
+ git(*command, dir: dir)
end
+ end
- stdout_with_no_credentials = URICredentialsFilter.credential_filtered_string(out, uri)
- raise GitCommandError.new(command_with_no_credentials, path, error_msg) if check_errors && !$?.success?
- stdout_with_no_credentials
+ def git(*command, dir: nil)
+ run_command(*command, dir: dir) do |unredacted_command|
+ check_allowed(unredacted_command)
+ end
+ end
+
+ def git_local(*command, dir: nil)
+ run_command(*command, dir: dir) do |unredacted_command|
+ redact_and_check_presence(unredacted_command)
+ end
end
def has_revision_cached?
- return unless @revision
- in_path { git("cat-file -e #{@revision}") }
+ return unless commit && path.exist?
+ git("cat-file", "-e", commit, dir: path)
true
rescue GitError
false
end
- def remove_cache
- FileUtils.rm_rf(path)
+ def find_local_revision
+ return head_revision if explicit_ref.nil?
+
+ find_revision_for(explicit_ref)
end
- def find_local_revision
- allowed_in_path do
- git("rev-parse --verify #{Shellwords.shellescape(ref)}", true).strip
- end
+ def head_revision
+ verify("HEAD")
end
- # Escape the URI for git commands
- def uri_escaped_with_configured_credentials
- remote = configured_uri_for(uri)
- if Bundler::WINDOWS
- # Windows quoting requires double quotes only, with double quotes
- # inside the string escaped by being doubled.
- '"' + remote.gsub('"') { '""' } + '"'
- else
- # Bash requires single quoted strings, with the single quotes escaped
- # by ending the string, escaping the quote, and restarting the string.
- "'" + remote.gsub("'") { "'\\''" } + "'"
- end
+ def find_revision_for(reference)
+ verify(reference)
+ rescue GitCommandError => e
+ raise MissingGitRevisionError.new(e.command, path, reference, credential_filtered_uri)
+ end
+
+ def verify(reference)
+ git("rev-parse", "--verify", reference, dir: path).strip
end
- # Adds credentials to the URI as Fetcher#configured_uri_for does
- def configured_uri_for(uri)
- if /https?:/ =~ uri
- remote = URI(uri)
+ # Adds credentials to the URI
+ def configured_uri
+ if /https?:/.match?(uri)
+ remote = Gem::URI(uri)
config_auth = Bundler.settings[remote.to_s] || Bundler.settings[remote.host]
remote.userinfo ||= config_auth
remote.to_s
else
- uri
+ uri.to_s
end
end
+ # Removes credentials from the URI
+ def credential_filtered_uri
+ URICredentialsFilter.credential_filtered_uri(uri)
+ end
+
def allow?
- @git ? @git.allow_git_ops? : true
+ allowed = @git ? @git.allow_git_ops? : true
+
+ raise GitNotInstalledError.new if allowed && !Bundler.git_present?
+
+ allowed
end
- def in_path(&blk)
+ def with_path(&blk)
checkout unless path.exist?
- _ = URICredentialsFilter # load it before we chdir
- SharedHelpers.chdir(path, &blk)
+ blk.call
end
- def allowed_in_path
- return in_path { yield } if allow?
+ def allowed_with_path
+ return with_path { yield } if allow?
raise GitError, "The git source #{uri} is not yet checked out. Please run `bundle install` before trying to start your application"
end
- # TODO: Replace this with Open3 when upgrading to bundler 2
- # Similar to #git_null, as Open3 is not cross-platform,
- # a temporary way is to use Tempfile to capture the stderr.
- # When replacing this using Open3, make sure git_null is
- # also replaced by Open3, so stdout and stderr all got handled properly.
- def capture_and_filter_stderr(uri)
- return_value, captured_err = ""
- backup_stderr = STDERR.dup
- begin
- Tempfile.open("captured_stderr") do |f|
- STDERR.reopen(f)
- return_value = yield
- f.rewind
- captured_err = f.read
- end
- ensure
- STDERR.reopen backup_stderr
+ def check_allowed(command)
+ command_with_no_credentials = redact_and_check_presence(command)
+ raise GitNotAllowedError.new(command_with_no_credentials) unless allow?
+ command_with_no_credentials
+ end
+
+ def redact_and_check_presence(command)
+ raise GitNotInstalledError.new unless Bundler.git_present?
+
+ require "shellwords"
+ URICredentialsFilter.credential_filtered_string("git #{command.shelljoin}", uri)
+ end
+
+ def run_command(*command, dir: nil)
+ command_with_no_credentials = yield(command)
+
+ out, err, status = capture(command, dir)
+
+ raise GitCommandError.new(command_with_no_credentials, dir || SharedHelpers.pwd, err) unless status.success?
+
+ Bundler.ui.warn err unless err.empty?
+
+ out
+ end
+
+ def capture(cmd, dir, ignore_err: false)
+ SharedHelpers.with_clean_git_env do
+ require "open3"
+ out, err, status = Open3.capture3(*capture3_args_for(cmd, dir))
+
+ filtered_out = URICredentialsFilter.credential_filtered_string(out, uri)
+ return [filtered_out, status] if ignore_err
+
+ filtered_err = URICredentialsFilter.credential_filtered_string(err, uri)
+ [filtered_out, filtered_err, status]
end
- Bundler.ui.warn URICredentialsFilter.credential_filtered_string(captured_err, uri) if uri && !captured_err.empty?
- return_value
+ end
+
+ def capture3_args_for(cmd, dir)
+ return ["git", *cmd] unless dir
+
+ ["git", "-C", dir.to_s, *cmd]
+ end
+
+ def extra_clone_args
+ args = depth_args
+ return [] if args.empty?
+
+ args += ["--single-branch"]
+ args.unshift("--no-tags") if supports_cloning_with_no_tags?
+
+ # If there's a locked revision, no need to clone any specific branch
+ # or tag, since we will end up checking out that locked revision
+ # anyways.
+ return args if @revision
+
+ args += ["--branch", branch_option] if branch_option
+ args
+ end
+
+ def depth_args
+ return [] if full_clone?
+
+ ["--depth", depth.to_s]
+ end
+
+ def extra_fetch_args(ref)
+ extra_args = [path.to_s, *depth_args]
+ extra_args.push(ref)
+ extra_args
+ end
+
+ def branch_option
+ branch || tag
+ end
+
+ def full_clone?
+ depth.nil?
+ end
+
+ def needs_allow_any_sha1_in_want?
+ @needs_allow_any_sha1_in_want ||= Gem::Version.new(version) <= Gem::Version.new("2.13.7")
+ end
+
+ def supports_fetching_unreachable_refs?
+ @supports_fetching_unreachable_refs ||= Gem::Version.new(version) >= Gem::Version.new("2.5.0")
+ end
+
+ def supports_cloning_with_no_tags?
+ @supports_cloning_with_no_tags ||= Gem::Version.new(version) >= Gem::Version.new("2.14.0-rc0")
end
end
end