From f50360f34964ab145671998116a74e20dce3252a Mon Sep 17 00:00:00 2001 From: James Couball Date: Thu, 21 Apr 2022 17:12:38 -0700 Subject: [PATCH] Add Git::URL #parse and #clone_to methods Signed-off-by: James Couball --- git.gemspec | 1 + lib/git.rb | 1 + lib/git/url.rb | 122 +++++++++++++++++++++++++++ tests/units/test_git_alt_uri.rb | 27 ++++++ tests/units/test_url.rb | 144 ++++++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+) create mode 100644 lib/git/url.rb create mode 100644 tests/units/test_git_alt_uri.rb create mode 100644 tests/units/test_url.rb diff --git a/git.gemspec b/git.gemspec index 8d974e28..53298c5a 100644 --- a/git.gemspec +++ b/git.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?(:required_rubygems_version=) s.requirements = ['git 1.6.0.0, or greater'] + s.add_runtime_dependency 'addressable', '~> 2.8' s.add_runtime_dependency 'rchardet', '~> 1.8' s.add_development_dependency 'bump', '~> 0.10' diff --git a/lib/git.rb b/lib/git.rb index 4ad1bd97..addb0d59 100644 --- a/lib/git.rb +++ b/lib/git.rb @@ -21,6 +21,7 @@ require 'git/status' require 'git/stash' require 'git/stashes' +require 'git/url' require 'git/version' require 'git/working_directory' require 'git/worktree' diff --git a/lib/git/url.rb b/lib/git/url.rb new file mode 100644 index 00000000..19fff385 --- /dev/null +++ b/lib/git/url.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'addressable/uri' + +module Git + # Methods for parsing a Git URL + # + # Any URL that can be passed to `git clone` can be parsed by this class. + # + # @see https://git-scm.com/docs/git-clone#_git_urls GIT URLs + # @see https://github.com/sporkmonger/addressable Addresable::URI + # + # @api public + # + class URL + # Regexp used to match a Git URL with an alternative SSH syntax + # such as `user@host:path` + # + GIT_ALTERNATIVE_SSH_SYNTAX = %r{ + ^ + (?:(?[^@/]+)@)? # user or nil + (?[^:/]+) # host is required + :(?!/) # : serparator is required, but must not be followed by / + (?.*?) # path is required + $ + }x.freeze + + # Parse a Git URL and return an Addressable::URI object + # + # The URI returned can be converted back to a string with 'to_s'. This is + # guaranteed to return the same URL string that was parsed. + # + # @example + # uri = Git::URL.parse('https://github.com/ruby-git/ruby-git.git') + # #=> # + # uri.scheme #=> "https" + # uri.host #=> "github.com" + # uri.path #=> "/ruby-git/ruby-git.git" + # + # Git::URL.parse('/Users/James/projects/ruby-git') + # #=> # + # + # @param url [String] the Git URL to parse + # + # @return [Addressable::URI] the parsed URI + # + def self.parse(url) + if !url.start_with?('file:') && (m = GIT_ALTERNATIVE_SSH_SYNTAX.match(url)) + GitAltURI.new(user: m[:user], host: m[:host], path: m[:path]) + else + Addressable::URI.parse(url) + end + end + + # The name `git clone` would use for the repository directory for the given URL + # + # @example + # Git::URL.clone_to('https://github.com/ruby-git/ruby-git.git') #=> 'ruby-git' + # + # @param url [String] the Git URL containing the repository directory + # + # @return [String] the name of the repository directory + # + def self.clone_to(url) + uri = parse(url) + path_parts = uri.path.split('/') + path_parts.pop if path_parts.last == '.git' + + path_parts.last.sub(/\.git$/, '') + end + end + + # The URI for git's alternative scp-like syntax + # + # This class is necessary to ensure that #to_s returns the same string + # that was passed to the initializer. + # + # @api public + # + class GitAltURI < Addressable::URI + # Create a new GitAltURI object + # + # @example + # uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git') + # uri.to_s #=> 'james@github.com/james/ruby-git' + # + # @param user [String, nil] the user from the URL or nil + # @param host [String] the host from the URL + # @param path [String] the path from the URL + # + def initialize(user:, host:, path:) + super(scheme: 'git-alt', user: user, host: host, path: path) + end + + # Convert the URI to a String + # + # Addressible::URI forces path to be absolute by prepending a '/' to the + # path. This method removes the '/' when converting back to a string + # since that is what is expected by git. The following is a valid git URL: + # + # `james@github.com:ruby-git/ruby-git.git` + # + # and the following (with the initial '/'' in the path) is NOT a valid git URL: + # + # `james@github.com:/ruby-git/ruby-git.git` + # + # @example + # uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git') + # uri.path #=> '/james/ruby-git' + # uri.to_s #=> 'james@github.com:james/ruby-git' + # + # @return [String] the URI as a String + # + def to_s + if user + "#{user}@#{host}:#{path[1..-1]}" + else + "#{host}:#{path[1..-1]}" + end + end + end +end diff --git a/tests/units/test_git_alt_uri.rb b/tests/units/test_git_alt_uri.rb new file mode 100644 index 00000000..b01ea1bb --- /dev/null +++ b/tests/units/test_git_alt_uri.rb @@ -0,0 +1,27 @@ +require 'test/unit' + +# Tests for the Git::GitAltURI class +# +class TestGitAltURI < Test::Unit::TestCase + def test_new + uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'ruby-git/ruby-git.git') + actual_attributes = uri.to_hash.delete_if { |_key, value| value.nil? } + expected_attributes = { + scheme: 'git-alt', + user: 'james', + host: 'github.com', + path: '/ruby-git/ruby-git.git' + } + assert_equal(expected_attributes, actual_attributes) + end + + def test_to_s + uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'ruby-git/ruby-git.git') + assert_equal('james@github.com:ruby-git/ruby-git.git', uri.to_s) + end + + def test_to_s_with_nil_user + uri = Git::GitAltURI.new(user: nil, host: 'github.com', path: 'ruby-git/ruby-git.git') + assert_equal('github.com:ruby-git/ruby-git.git', uri.to_s) + end +end diff --git a/tests/units/test_url.rb b/tests/units/test_url.rb new file mode 100644 index 00000000..6eee2a8b --- /dev/null +++ b/tests/units/test_url.rb @@ -0,0 +1,144 @@ +require 'test/unit' + +GIT_URLS = [ + { + url: 'ssh://host.xz/path/to/repo.git/', + expected_attributes: { scheme: 'ssh', host: 'host.xz', path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'ssh://host.xz:4443/path/to/repo.git/', + expected_attributes: { scheme: 'ssh', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'ssh:///path/to/repo.git/', + expected_attributes: { scheme: 'ssh', host: '', path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'user@host.xz:path/to/repo.git/', + expected_attributes: { scheme: 'git-alt', user: 'user', host: 'host.xz', path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'host.xz:path/to/repo.git/', + expected_attributes: { scheme: 'git-alt', host: 'host.xz', path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'git://host.xz:4443/path/to/repo.git/', + expected_attributes: { scheme: 'git', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'git://user@host.xz:4443/path/to/repo.git/', + expected_attributes: { scheme: 'git', user: 'user', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'https://host.xz/path/to/repo.git/', + expected_attributes: { scheme: 'https', host: 'host.xz', path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'https://host.xz:4443/path/to/repo.git/', + expected_attributes: { scheme: 'https', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'ftps://host.xz:4443/path/to/repo.git/', + expected_attributes: { scheme: 'ftps', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'ftps://host.xz:4443/path/to/repo.git/', + expected_attributes: { scheme: 'ftps', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'file:./relative-path/to/repo.git/', + expected_attributes: { scheme: 'file', path: './relative-path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'file:///path/to/repo.git/', + expected_attributes: { scheme: 'file', host: '', path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: 'file:///path/to/repo.git', + expected_attributes: { scheme: 'file', host: '', path: '/path/to/repo.git' }, + expected_clone_to: 'repo' + }, + { + url: 'file://host.xz/path/to/repo.git', + expected_attributes: { scheme: 'file', host: 'host.xz', path: '/path/to/repo.git' }, + expected_clone_to: 'repo' + }, + { + url: '/path/to/repo.git/', + expected_attributes: { path: '/path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: '/path/to/bare-repo/.git', + expected_attributes: { path: '/path/to/bare-repo/.git' }, + expected_clone_to: 'bare-repo' + }, + { + url: 'relative-path/to/repo.git/', + expected_attributes: { path: 'relative-path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: './relative-path/to/repo.git/', + expected_attributes: { path: './relative-path/to/repo.git/' }, + expected_clone_to: 'repo' + }, + { + url: '../ruby-git/.git', + expected_attributes: { path: '../ruby-git/.git' }, + expected_clone_to: 'ruby-git' + } +].freeze + +# Tests for the Git::URL class +# +class TestURL < Test::Unit::TestCase + def test_parse_with_invalid_url + url = 'user@host.xz:/path/to/repo.git/' + assert_raise(Addressable::URI::InvalidURIError) do + Git::URL.parse(url) + end + end + + def test_parse + GIT_URLS.each do |url_data| + url = url_data[:url] + expected_attributes = url_data[:expected_attributes] + actual_attributes = Git::URL.parse(url).to_hash.delete_if {| key, value | value.nil? } + assert_equal(expected_attributes, actual_attributes, "Failed to parse URL '#{url}' correctly") + end + end + + def test_clone_to + GIT_URLS.each do |url_data| + url = url_data[:url] + expected_clone_to = url_data[:expected_clone_to] + actual_repo_name = Git::URL.clone_to(url) + assert_equal( + expected_clone_to, actual_repo_name, + "Failed to determine the repository directory for URL '#{url}' correctly" + ) + end + end + + def test_to_s + GIT_URLS.each do |url_data| + url = url_data[:url] + to_s = Git::URL.parse(url).to_s + assert_equal(url, to_s, "Parsed URI#to_s does not return the original URL '#{url}' correctly") + end + end +end