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