Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Git::URL .parse and .clone_to methods #575

Merged
merged 1 commit into from Apr 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions git.gemspec
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions lib/git.rb
Expand Up @@ -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'
Expand Down
122 changes: 122 additions & 0 deletions 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>[^@/]+)@)? # user or nil
(?<host>[^:/]+) # host is required
:(?!/) # : serparator is required, but must not be followed by /
(?<path>.*?) # 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')
# #=> #<Addressable::URI:0x44c URI: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')
# #=> #<Addressable::URI:0x438 URI:/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
27 changes: 27 additions & 0 deletions 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
144 changes: 144 additions & 0 deletions 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