diff --git a/README.md b/README.md
index 78181f20..661fad7a 100644
--- a/README.md
+++ b/README.md
@@ -197,6 +197,8 @@ g.show('v2.8', 'README.md')
Git.ls_remote('https://github.com/ruby-git/ruby-git.git') # returns a hash containing the available references of the repo.
Git.ls_remote('/path/to/local/repo')
Git.ls_remote() # same as Git.ls_remote('.')
+
+Git.default_branch('https://github.com/ruby-git/ruby-git') #=> 'master'
```
And here are the operations that will need to write to your git repository.
diff --git a/lib/git.rb b/lib/git.rb
index c32ef896..10f25d60 100644
--- a/lib/git.rb
+++ b/lib/git.rb
@@ -175,6 +175,46 @@ def self.clone(repository_url, directory = nil, options = {})
Base.clone(repository_url, directory, options)
end
+ # Returns the name of the default branch of the given repository
+ #
+ # @example with a URI string
+ # Git.default_branch('https://github.com/ruby-git/ruby-git') # => 'master'
+ # Git.default_branch('https://github.com/rspec/rspec-core') # => 'main'
+ #
+ # @example with a URI object
+ # repository_uri = URI('https://github.com/ruby-git/ruby-git')
+ # Git.default_branch(repository_uri) # => 'master'
+ #
+ # @example with a local repository
+ # Git.default_branch('.') # => 'master'
+ #
+ # @example with a local repository Pathname
+ # repository_path = Pathname('.')
+ # Git.default_branch(repository_path) # => 'master'
+ #
+ # @example with the logging option
+ # logger = Logger.new(STDOUT, level: Logger::INFO)
+ # Git.default_branch('.', log: logger) # => 'master'
+ # I, [2022-04-13T16:01:33.221596 #18415] INFO -- : git '-c' 'core.quotePath=true' '-c' 'color.ui=false' ls-remote '--symref' '--' '.' 'HEAD' 2>&1
+ #
+ # @param repository [URI, Pathname, String] The (possibly remote) repository to get the default branch name for
+ #
+ # See [GIT URLS](https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a)
+ # for more information.
+ #
+ # @param [Hash] options The options for this command (see list of valid
+ # options below)
+ #
+ # @option options [Logger] :log A logger to use for Git operations. Git
+ # commands are logged at the `:info` level. Additional logging is done
+ # at the `:debug` level.
+ #
+ # @return [String] the name of the default branch
+ #
+ def self.default_branch(repository, options = {})
+ Base.repository_default_branch(repository, options)
+ end
+
# Export the current HEAD (or a branch, if options[:branch]
# is specified) into the +name+ directory, then remove all traces of git from the
# directory.
diff --git a/lib/git/base.rb b/lib/git/base.rb
index c89b8315..ca3855a1 100644
--- a/lib/git/base.rb
+++ b/lib/git/base.rb
@@ -24,6 +24,11 @@ def self.clone(repository_url, directory, options = {})
new(new_options)
end
+ # (see Git.default_branch)
+ def self.repository_default_branch(repository, options = {})
+ Git::Lib.new(nil, options[:log]).repository_default_branch(repository)
+ end
+
# Returns (and initialize if needed) a Git::Config instance
#
# @return [Git::Config] the current config instance.
diff --git a/lib/git/lib.rb b/lib/git/lib.rb
index 4a685944..74b31862 100644
--- a/lib/git/lib.rb
+++ b/lib/git/lib.rb
@@ -124,6 +124,24 @@ def return_base_opts_from_clone(clone_dir, opts)
base_opts
end
+ # Returns the name of the default branch of the given repository
+ #
+ # @param repository [URI, Pathname, String] The (possibly remote) repository to clone from
+ #
+ # @return [String] the name of the default branch
+ #
+ def repository_default_branch(repository)
+ output = command('ls-remote', '--symref', '--', repository, 'HEAD')
+
+ match_data = output.match(%r{^ref: refs/remotes/origin/(?[^\t]+)\trefs/remotes/origin/HEAD$})
+ return match_data[:default_branch] if match_data
+
+ match_data = output.match(%r{^ref: refs/heads/(?[^\t]+)\tHEAD$})
+ return match_data[:default_branch] if match_data
+
+ raise 'Unable to determine the default branch'
+ end
+
## READ COMMANDS ##
#
diff --git a/tests/units/test_git_default_branch.rb b/tests/units/test_git_default_branch.rb
new file mode 100644
index 00000000..3b1f64fd
--- /dev/null
+++ b/tests/units/test_git_default_branch.rb
@@ -0,0 +1,41 @@
+#!/usr/bin/env ruby
+
+require File.dirname(__FILE__) + '/../test_helper'
+
+require 'logger'
+require 'stringio'
+
+# Tests for Git::Lib#repository_default_branch
+#
+class TestLibRepositoryDefaultBranch < Test::Unit::TestCase
+ def test_default_branch
+ repository = 'new_repo'
+ in_temp_dir do
+ create_local_repository(repository, initial_branch: 'main')
+ assert_equal('main', Git.default_branch(repository))
+ end
+ end
+
+ def test_default_branch_with_logging
+ repository = 'new_repo'
+ in_temp_dir do
+ create_local_repository(repository, initial_branch: 'main')
+ log_device = StringIO.new
+ logger = Logger.new(log_device, level: Logger::INFO)
+ Git.default_branch(repository, log: logger)
+ assert_match(/git.*ls-remote/, log_device.string)
+ end
+ end
+
+ private
+
+ def create_local_repository(subdirectory, initial_branch: 'main')
+ git = Git.init(subdirectory, initial_branch: initial_branch)
+
+ FileUtils.cd(subdirectory) do
+ File.write('README.md', '# This is a README')
+ git.add('README.md')
+ git.commit('Initial commit')
+ end
+ end
+end
diff --git a/tests/units/test_lib_repository_default_branch.rb b/tests/units/test_lib_repository_default_branch.rb
new file mode 100644
index 00000000..dea8bf0f
--- /dev/null
+++ b/tests/units/test_lib_repository_default_branch.rb
@@ -0,0 +1,96 @@
+#!/usr/bin/env ruby
+
+require File.dirname(__FILE__) + '/../test_helper'
+
+# Tests for Git::Lib#repository_default_branch
+#
+class TestLibRepositoryDefaultBranch < Test::Unit::TestCase
+ def setup
+ clone_working_repo
+ @git = Git.open(@wdir)
+
+ @lib = Git.open(@wdir).lib
+ end
+
+ # This is the one real test that actually calls git. The rest of the tests
+ # mock Git::Lib#command to return specific responses.
+ #
+ def test_local_repository
+ in_temp_dir do
+ git = Git.init('new_repo', initial_branch: 'main')
+ FileUtils.cd('new_repo') do
+ File.write('README.md', '# This is a README')
+ git.add('README.md')
+ git.commit('Initial commit')
+ end
+ FileUtils.touch('new_repo/README.md')
+
+ assert_equal('main', @lib.repository_default_branch('new_repo'))
+ end
+ end
+
+ def mock_command(lib, repository, response)
+ test_case = self
+ lib.define_singleton_method(:command) do |cmd, *opts, &_block|
+ test_case.assert_equal('ls-remote', cmd)
+ test_case.assert_equal(['--symref', '--', repository, 'HEAD'], opts.flatten)
+ response
+ end
+ end
+
+ def test_remote_repository
+ repository = 'https://github.com/ruby-git/ruby-git'
+ mock_command(@lib, repository, <<~RESPONSE)
+ ref: refs/heads/default_branch\tHEAD
+ 292087efabc8423c3cf616d78fac5311d58e7425\tHEAD
+ RESPONSE
+ assert_equal('default_branch', @lib.repository_default_branch(repository))
+ end
+
+ def test_local_repository_with_origin
+ repository = 'https://github.com/ruby-git/ruby-git'
+ mock_command(@lib, repository, <<~RESPONSE)
+ ref: refs/heads/master\tHEAD
+ 292087efabc8423c3cf616d78fac5311d58e7425\tHEAD
+ ref: refs/remotes/origin/default_branch\trefs/remotes/origin/HEAD
+ 292087efabc8423c3cf616d78fac5311d58e7425\trefs/remotes/origin/HEAD
+ RESPONSE
+ assert_equal('default_branch', @lib.repository_default_branch(repository))
+ end
+
+ def test_local_repository_without_remotes
+ repository = '.'
+ mock_command(@lib, repository, <<~RESPONSE)
+ ref: refs/heads/default_branch\tHEAD
+ d7b79c31113c42c7aa3fe915186c1d6bcd3fbd39\tHEAD
+ RESPONSE
+ assert_equal('default_branch', @lib.repository_default_branch(repository))
+ end
+
+ def test_repository_with_no_commits
+ # Local or remote, the result is the same
+ repository = '.'
+ mock_command(@lib, repository, '')
+ assert_raise_with_message(RuntimeError, 'Unable to determine the default branch') do
+ @lib.repository_default_branch(repository)
+ end
+ end
+
+ def test_repository_not_found
+ # Local or remote, the result is the same
+ repository = 'does_not_exist'
+ assert_raise(Git::FailedError) do
+ @lib.repository_default_branch(repository)
+ end
+ end
+
+ def test_not_a_repository
+ in_temp_dir do
+ repository = 'exists_but_not_a_repository'
+ FileUtils.mkdir repository
+ assert_raise(Git::FailedError) do
+ @lib.repository_default_branch(repository)
+ end
+ end
+ end
+end