Skip to content

Commit a2a1ab8

Browse files
committed
Add Git::URL #parse and #clone_to methods
Signed-off-by: James Couball <jcouball@yahoo.com>
1 parent 0a43d8b commit a2a1ab8

File tree

5 files changed

+288
-0
lines changed

5 files changed

+288
-0
lines changed

git.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Gem::Specification.new do |s|
2626
s.required_rubygems_version = Gem::Requirement.new('>= 0') if s.respond_to?(:required_rubygems_version=)
2727
s.requirements = ['git 1.6.0.0, or greater']
2828

29+
s.add_runtime_dependency 'addressable', '~> 2.8'
2930
s.add_runtime_dependency 'rchardet', '~> 1.8'
3031

3132
s.add_development_dependency 'bump', '~> 0.10'

lib/git.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
require 'git/status'
2222
require 'git/stash'
2323
require 'git/stashes'
24+
require 'git/url'
2425
require 'git/version'
2526
require 'git/working_directory'
2627
require 'git/worktree'

lib/git/url.rb

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# frozen_string_literal: true
2+
3+
require 'addressable/uri'
4+
5+
module Git
6+
# Methods for parsing a Git URL
7+
#
8+
# Any URL that can be passed to `git clone` can be parsed by this class.
9+
#
10+
# @see https://git-scm.com/docs/git-clone#_git_urls GIT URLs
11+
# @see https://github.com/sporkmonger/addressable Addresable::URI
12+
#
13+
# @api public
14+
#
15+
class URL
16+
# Regexp used to match a Git URL with an alternative SSH syntax
17+
# such as `user@host:path`
18+
#
19+
GIT_ALTERNATIVE_SSH_SYNTAX = %r{
20+
^
21+
(?:(?<user>[^@/]+)@)? # user or nil
22+
(?<host>[^:/]+) # host is required
23+
:(?!/) # : serparator is required, but must not be followed by /
24+
(?<path>.*?) # path is required
25+
$
26+
}x.freeze
27+
28+
# Parse a Git URL and return an Addressable::URI object
29+
#
30+
# The URI returned can be converted back to a string with 'to_s'. This is
31+
# guaranteed to return the same URL string that was parsed.
32+
#
33+
# @example
34+
# uri = Git::URL.parse('https://github.com/ruby-git/ruby-git.git')
35+
# #=> #<Addressable::URI:0x44c URI:https://github.com/ruby-git/ruby-git.git>
36+
# uri.scheme #=> "https"
37+
# uri.host #=> "github.com"
38+
# uri.path #=> "/ruby-git/ruby-git.git"
39+
#
40+
# Git::URL.parse('/Users/James/projects/ruby-git')
41+
# #=> #<Addressable::URI:0x438 URI:/Users/James/projects/ruby-git>
42+
#
43+
# @param url [String] the Git URL to parse
44+
#
45+
# @return [Addressable::URI] the parsed URI
46+
#
47+
def self.parse(url)
48+
if !url.start_with?('file:') && (m = GIT_ALTERNATIVE_SSH_SYNTAX.match(url))
49+
GitAltURI.new(user: m[:user], host: m[:host], path: m[:path])
50+
else
51+
Addressable::URI.parse(url)
52+
end
53+
end
54+
55+
# The name `git clone` would use for the repository directory for the given URL
56+
#
57+
# @example
58+
# Git::URL.clone_to('https://github.com/ruby-git/ruby-git.git') #=> 'ruby-git'
59+
#
60+
# @param url [String] the Git URL containing the repository directory
61+
#
62+
# @return [String] the name of the repository directory
63+
#
64+
def self.clone_to(url)
65+
uri = parse(url)
66+
path_parts = uri.path.split('/')
67+
path_parts.pop if path_parts.last == '.git'
68+
69+
path_parts.last.sub(/\.git$/, '')
70+
end
71+
end
72+
73+
# The URI for git's alternative scp-like syntax
74+
#
75+
# This class is necessary to ensure that #to_s returns the same string
76+
# that was passed to the initializer.
77+
#
78+
# @api public
79+
#
80+
class GitAltURI < Addressable::URI
81+
# Create a new GitAltURI object
82+
#
83+
# @example
84+
# uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git')
85+
# uri.to_s #=> 'james@github.com/james/ruby-git'
86+
#
87+
# @param user [String, nil] the user from the URL or nil
88+
# @param host [String] the host from the URL
89+
# @param path [String] the path from the URL
90+
#
91+
def initialize(user:, host:, path:)
92+
super(scheme: 'git-alt', user: user, host: host, path: path)
93+
end
94+
95+
# Convert the URI to a String
96+
#
97+
# Addressible::URI forces path to be absolute by prepending a '/' to the
98+
# path. This method removes the '/' when converting back to a string
99+
# since that is what is expected by git. The following is a valid git URL:
100+
#
101+
# `james@github.com:ruby-git/ruby-git.git`
102+
#
103+
# and the following (with the initial '/'' in the path) is NOT a valid git URL:
104+
#
105+
# `james@github.com:/ruby-git/ruby-git.git`
106+
#
107+
# @example
108+
# uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git')
109+
# uri.path #=> '/james/ruby-git'
110+
# uri.to_s #=> 'james@github.com:james/ruby-git'
111+
#
112+
# @return [String] the URI as a String
113+
#
114+
def to_s
115+
if user
116+
"#{user}@#{host}:#{path[1..-1]}"
117+
else
118+
"#{host}:#{path[1..-1]}"
119+
end
120+
end
121+
end
122+
end

tests/units/test_git_alt_uri.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
require 'test/unit'
2+
3+
# Tests for the Git::GitAltURI class
4+
#
5+
class TestGitAltURI < Test::Unit::TestCase
6+
def test_new
7+
uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'ruby-git/ruby-git.git')
8+
actual_attributes = uri.to_hash.delete_if { |_key, value| value.nil? }
9+
expected_attributes = {
10+
scheme: 'git-alt',
11+
user: 'james',
12+
host: 'github.com',
13+
path: '/ruby-git/ruby-git.git'
14+
}
15+
assert_equal(expected_attributes, actual_attributes)
16+
end
17+
18+
def test_to_s
19+
uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'ruby-git/ruby-git.git')
20+
assert_equal('james@github.com:ruby-git/ruby-git.git', uri.to_s)
21+
end
22+
23+
def test_to_s_with_nil_user
24+
uri = Git::GitAltURI.new(user: nil, host: 'github.com', path: 'ruby-git/ruby-git.git')
25+
assert_equal('github.com:ruby-git/ruby-git.git', uri.to_s)
26+
end
27+
end

tests/units/test_url.rb

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
require 'test/unit'
2+
3+
GIT_URLS = [
4+
{
5+
url: 'ssh://host.xz/path/to/repo.git/',
6+
expected_attributes: { scheme: 'ssh', host: 'host.xz', path: '/path/to/repo.git/' },
7+
expected_clone_to: 'repo'
8+
},
9+
{
10+
url: 'ssh://host.xz:4443/path/to/repo.git/',
11+
expected_attributes: { scheme: 'ssh', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' },
12+
expected_clone_to: 'repo'
13+
},
14+
{
15+
url: 'ssh:///path/to/repo.git/',
16+
expected_attributes: { scheme: 'ssh', host: '', path: '/path/to/repo.git/' },
17+
expected_clone_to: 'repo'
18+
},
19+
{
20+
url: 'user@host.xz:path/to/repo.git/',
21+
expected_attributes: { scheme: 'git-alt', user: 'user', host: 'host.xz', path: '/path/to/repo.git/' },
22+
expected_clone_to: 'repo'
23+
},
24+
{
25+
url: 'host.xz:path/to/repo.git/',
26+
expected_attributes: { scheme: 'git-alt', host: 'host.xz', path: '/path/to/repo.git/' },
27+
expected_clone_to: 'repo'
28+
},
29+
{
30+
url: 'git://host.xz:4443/path/to/repo.git/',
31+
expected_attributes: { scheme: 'git', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' },
32+
expected_clone_to: 'repo'
33+
},
34+
{
35+
url: 'git://user@host.xz:4443/path/to/repo.git/',
36+
expected_attributes: { scheme: 'git', user: 'user', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' },
37+
expected_clone_to: 'repo'
38+
},
39+
{
40+
url: 'https://host.xz/path/to/repo.git/',
41+
expected_attributes: { scheme: 'https', host: 'host.xz', path: '/path/to/repo.git/' },
42+
expected_clone_to: 'repo'
43+
},
44+
{
45+
url: 'https://host.xz:4443/path/to/repo.git/',
46+
expected_attributes: { scheme: 'https', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' },
47+
expected_clone_to: 'repo'
48+
},
49+
{
50+
url: 'ftps://host.xz:4443/path/to/repo.git/',
51+
expected_attributes: { scheme: 'ftps', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' },
52+
expected_clone_to: 'repo'
53+
},
54+
{
55+
url: 'ftps://host.xz:4443/path/to/repo.git/',
56+
expected_attributes: { scheme: 'ftps', host: 'host.xz', port: 4443, path: '/path/to/repo.git/' },
57+
expected_clone_to: 'repo'
58+
},
59+
{
60+
url: 'file:./relative-path/to/repo.git/',
61+
expected_attributes: { scheme: 'file', path: './relative-path/to/repo.git/' },
62+
expected_clone_to: 'repo'
63+
},
64+
{
65+
url: 'file:///path/to/repo.git/',
66+
expected_attributes: { scheme: 'file', host: '', path: '/path/to/repo.git/' },
67+
expected_clone_to: 'repo'
68+
},
69+
{
70+
url: 'file:///path/to/repo.git',
71+
expected_attributes: { scheme: 'file', host: '', path: '/path/to/repo.git' },
72+
expected_clone_to: 'repo'
73+
},
74+
{
75+
url: 'file://host.xz/path/to/repo.git',
76+
expected_attributes: { scheme: 'file', host: 'host.xz', path: '/path/to/repo.git' },
77+
expected_clone_to: 'repo'
78+
},
79+
{
80+
url: '/path/to/repo.git/',
81+
expected_attributes: { path: '/path/to/repo.git/' },
82+
expected_clone_to: 'repo'
83+
},
84+
{
85+
url: '/path/to/bare-repo/.git',
86+
expected_attributes: { path: '/path/to/bare-repo/.git' },
87+
expected_clone_to: 'bare-repo'
88+
},
89+
{
90+
url: 'relative-path/to/repo.git/',
91+
expected_attributes: { path: 'relative-path/to/repo.git/' },
92+
expected_clone_to: 'repo'
93+
},
94+
{
95+
url: './relative-path/to/repo.git/',
96+
expected_attributes: { path: './relative-path/to/repo.git/' },
97+
expected_clone_to: 'repo'
98+
},
99+
{
100+
url: '../ruby-git/.git',
101+
expected_attributes: { path: '../ruby-git/.git' },
102+
expected_clone_to: 'ruby-git'
103+
}
104+
].freeze
105+
106+
# Tests for the Git::URL class
107+
#
108+
class TestURL < Test::Unit::TestCase
109+
def test_parse
110+
GIT_URLS.each do |url_data|
111+
url = url_data[:url]
112+
expected_attributes = url_data[:expected_attributes]
113+
actual_attributes = Git::URL.parse(url).to_hash.delete_if {| key, value | value.nil? }
114+
assert_equal(expected_attributes, actual_attributes, "Failed to parse URL '#{url}' correctly")
115+
end
116+
end
117+
118+
def test_clone_to
119+
GIT_URLS.each do |url_data|
120+
url = url_data[:url]
121+
expected_clone_to = url_data[:expected_clone_to]
122+
actual_repo_name = Git::URL.clone_to(url)
123+
assert_equal(
124+
expected_clone_to, actual_repo_name,
125+
"Failed to determine the repository directory for URL '#{url}' correctly"
126+
)
127+
end
128+
end
129+
130+
def test_to_s
131+
GIT_URLS.each do |url_data|
132+
url = url_data[:url]
133+
to_s = Git::URL.parse(url).to_s
134+
assert_equal(url, to_s, "Parsed URI#to_s does not return the original URL '#{url}' correctly")
135+
end
136+
end
137+
end

0 commit comments

Comments
 (0)