giturl 1.3.6 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 573b19763ae39da18a0c44e608af15eb32e28ed76a1ec7a40330fdd6279a849a
4
- data.tar.gz: c4af8e33c8a67d2c9f5e6459f0da798c5ad3ea315df2a2d1af61fe293d0f4707
3
+ metadata.gz: 6ff2df69734b9bef7d1cab4aec2988a50ca7a0aab7385c95a9dff717b993f842
4
+ data.tar.gz: 7357c5095c2ef759766fde7b7f88d0ab03a33fbfc0d1bc8e5f7d576a8e35c1a2
5
5
  SHA512:
6
- metadata.gz: 210f26eb0a8fa88f37e134bd3d133fc02d8a2a535d2e4fc982fc088f390846046b6ae63598322552cf8f0bfde53e0dd7ae3fe3f7ecd38df0e2ac5fb5d624e0ca
7
- data.tar.gz: 66e722c6696b5e9d8ce8e44b889511a372fb501c34067e4d9fe1fa161bd12b3e1fc962c91b375e2e7db0cdb8d3743030c1a14ba6916af8f826b7430fe9607829
6
+ metadata.gz: 529768719225bc69023f48ff584d051dcc6d962d7e3910173d3448cdc23c12f9b894039677430d8c8abedd5ab2b738823eb786e6c4f67e3f0f90f75604010b4b
7
+ data.tar.gz: ab9a6c1986efe1d11d548ccbc7e7bb4d4dd7dbb062fc52d6594c3007920f9366b070f7565ffcb92f0ca40d46b0d86d5d5879b121925e0c3f0a0b846bd93135a6
data/.rubocop.yml CHANGED
@@ -12,7 +12,7 @@ plugins:
12
12
  - rubocop-rspec
13
13
 
14
14
  AllCops:
15
- TargetRubyVersion: 2.3
15
+ TargetRubyVersion: 3.2
16
16
  NewCops: enable
17
17
 
18
18
  Layout/EndAlignment:
@@ -41,7 +41,7 @@ Metrics/BlockLength:
41
41
 
42
42
  Metrics/MethodLength:
43
43
  Enabled: true
44
- Max: 30
44
+ Max: 25
45
45
 
46
46
  Style/ExponentialNotation:
47
47
  Enabled: true
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## [v1.4.1](https://github.com/shinyaohtani/giturl/tree/v1.4.1) (2026-09-05)
4
+
5
+ [Full Changelog](https://github.com/shinyaohtani/giturl/compare/v1.4.0...v1.4.1)
6
+
7
+ - The first release on RubyGems to require Ruby 3.2, since v1.4.0 was
8
+ tagged but never pushed there. Everything v1.4.0 describes applies
9
+ here too, so read its entry below as part of this release.
10
+ - Directories whose name contains a space or a shell metacharacter now
11
+ work, and remotes written as a URL (`https://`, `ssh://`, `git://`)
12
+ now convert correctly.
13
+
14
+ **Fixed bugs:**
15
+
16
+ - https:// remotes produce a malformed URL \(https///github.com/...\) [\#41](https://github.com/shinyaohtani/giturl/issues/41)
17
+ - Paths containing spaces or shell metacharacters are not escaped [\#40](https://github.com/shinyaohtani/giturl/issues/40)
18
+
19
+ **Merged pull requests:**
20
+
21
+ - Run git without a shell, and parse remotes properly [\#44](https://github.com/shinyaohtani/giturl/pull/44) ([shinyaohtani](https://github.com/shinyaohtani))
22
+
23
+ ## [v1.4.0](https://github.com/shinyaohtani/giturl/tree/v1.4.0) (2026-09-04)
24
+
25
+ [Full Changelog](https://github.com/shinyaohtani/giturl/compare/v1.3.6...v1.4.0)
26
+
27
+ - Tagged but never published to RubyGems: two bugs were found before it
28
+ was pushed, so v1.4.1 went out in its place with the same changes plus
29
+ those fixes. `gem install giturl` never sees this version.
30
+ - **Ruby 3.2 or later is now required.**
31
+ - On Ruby 2.x, install the last release that supported it: `gem install giturl -v 1.3.6`
32
+ - No changes in behavior
33
+ - `Giturl::Giturl.url` / `.convert` / `.git_managed?` keep their signatures and results.
34
+ - The code behind them was reorganized into classes that each hold one responsibility.
35
+
36
+ **Closed issues:**
37
+
38
+ - Refactor into noun classes: behavior lives in class methods, responsibilities are mixed [\#43](https://github.com/shinyaohtani/giturl/issues/43)
39
+
40
+ **Merged pull requests:**
41
+
42
+ - Refactor into noun classes per the Ruby refactoring standard [\#42](https://github.com/shinyaohtani/giturl/pull/42) ([shinyaohtani](https://github.com/shinyaohtani))
43
+
3
44
  ## [v1.3.6](https://github.com/shinyaohtani/giturl/tree/v1.3.6) (2026-09-03)
4
45
 
5
46
  [Full Changelog](https://github.com/shinyaohtani/giturl/compare/v1.3.5...v1.3.6)
data/README.md CHANGED
@@ -96,6 +96,13 @@ Or add `giturl` to your application's Gemfile and run `bundle` command:
96
96
  gem 'giturl'
97
97
  ```
98
98
 
99
+ ### Ruby version
100
+
101
+ `giturl` requires **Ruby 3.2 or later**. On an older Ruby, install the last
102
+ release that supported Ruby 2.x:
103
+
104
+ $ gem install giturl -v 1.3.6
105
+
99
106
  ## Giturl module
100
107
 
101
108
  `Giturl` is also a module, so you can get urls from your ruby code.
data/giturl.gemspec CHANGED
@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
12
12
  spec.description = Giturl::DESCRIPTION
13
13
  spec.homepage = "#{Giturl::REPOSITORY_URL}/blob/master/README.md"
14
14
  spec.license = 'MIT'
15
- spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
15
+ spec.required_ruby_version = Gem::Requirement.new('>= 3.2.0')
16
16
 
17
17
  spec.metadata['allowed_push_host'] = 'https://rubygems.org'
18
18
 
@@ -1,71 +1,56 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ require 'launchy'
5
+ require 'giturl'
6
+ require 'giturl/options'
7
+
4
8
  # converter module from path to url
5
9
  module Giturl
6
- require 'optparse'
7
- require 'giturl'
8
- require 'launchy'
9
-
10
- # command line wrapper
10
+ # The giturl command: the directories named on the command line, and what the
11
+ # user asked to have done with them.
11
12
  class CommandGiturl
12
13
  attr_accessor :params
13
14
 
14
- def parse_options
15
+ def initialize
16
+ @options = Options.new
15
17
  @params = {}
16
- OptionParser.new do |opts|
17
- opts = define_options(opts)
18
- opts.parse!(ARGV, into: @params)
19
- @params[:open] = true if @params.key?(:app)
20
- end
21
18
  end
22
19
 
20
+ # @return [Hash] the options given on the command line
21
+ def parse_options
22
+ @params = @options.parse!(ARGV)
23
+ end
24
+
25
+ # Prints a URL per target directory, and opens each in a browser when asked.
26
+ # With no target given, the current directory is the target.
23
27
  def run
24
28
  ARGV << '.' if ARGV.empty?
25
- ARGV.each do |arg|
26
- if Giturl.git_managed?(arg)
27
- url = Giturl.convert(arg)
28
- print "#{url}\n"
29
- browser_open(url) if @params[:open]
30
- elsif @params[:verbose]
31
- print "Not git-managed-dir: #{arg}\n"
32
- end
33
- end
29
+ ARGV.each { |target| show(target) }
34
30
  end
35
31
 
36
32
  private
37
33
 
38
- def define_options(opts)
39
- opts.version = VERSION
40
- opts.on('-o', '--open', 'Open the URL in your browser. default: no') { |v| v }
41
- opts.on('-a [APPNAME]', '--app [APPNAME]', 'Specify a browser. i.e. "Safari.app"') { |v| v }
42
- opts.on('-v', '--verbose', 'Verbose mode. default: no') { |v| v }
43
- opts.on_tail('-h', '--help', 'Show this message') do
44
- puts opts
45
- exit
34
+ # @param target [String] a directory named on the command line
35
+ def show(target)
36
+ directory = GitDirectory.new(target)
37
+ unless directory.managed?
38
+ print "Not git-managed-dir: #{target}\n" if @params[:verbose]
39
+ return
46
40
  end
47
- opts.on_tail('-V', '--version', 'Show version') do
48
- puts opts.ver
49
- exit
50
- end
51
- opts.banner = <<~BANNER
52
-
53
- #{opts.ver}
54
- #{DESCRIPTION}
55
- Usage: #{opts.program_name} [options] [dirs]
56
- [dirs]:
57
- Target directories. Omit this when you only specify "."
58
41
 
59
- [options]:
60
- BANNER
61
- opts
42
+ url = directory.url
43
+ print "#{url}\n"
44
+ browser_open(url) if @params[:open]
62
45
  end
63
46
 
47
+ # Launchy cannot pick a specific application, so a named browser goes
48
+ # through macOS `open` instead, where that is what the user is running.
49
+ #
50
+ # @param url [String] the URL to show
64
51
  def browser_open(url)
65
52
  if @params[:app] && Launchy::Detect::HostOsFamily.detect.darwin?
66
- comm = +"open #{url}"
67
- comm << " -a #{@params[:app]}" if @params[:app]
68
- system(comm)
53
+ system("open #{url} -a #{@params[:app]}")
69
54
  else
70
55
  Launchy.open(url)
71
56
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'giturl/location'
4
+ require 'giturl/remote_url'
5
+
6
+ module Giturl
7
+ # A directory on disk, asked about through git: whether git manages it, and
8
+ # what GitHub page shows it. Every git invocation in giturl happens here.
9
+ class GitDirectory
10
+ FAILURE = 'Git commands failed. Please check if the directory is a git repository.'
11
+
12
+ def initialize(path)
13
+ @path = path
14
+ end
15
+
16
+ # git prints to stderr when the path is not a repository, which is noise for
17
+ # a question that is allowed to answer "no", so stderr is silenced around it.
18
+ #
19
+ # @return [Boolean] whether the directory is inside a git work tree
20
+ def managed?
21
+ stderr_old = $stderr.dup
22
+ $stderr.reopen(File::NULL)
23
+ inside, = capture('rev-parse', '--is-inside-work-tree')
24
+ $stderr.flush
25
+ $stderr.reopen stderr_old
26
+ inside == 'true'
27
+ end
28
+
29
+ # @return [String] the GitHub page URL for this directory, or '' (after a
30
+ # warning) when git could not answer
31
+ def url
32
+ here = location
33
+ unless here
34
+ warn FAILURE
35
+ return ''
36
+ end
37
+
38
+ "#{RemoteUrl.new(here.remote).https}/tree/#{here.encoded_branch}/#{here.prefix}"
39
+ end
40
+
41
+ private
42
+
43
+ # All three commands run before the result is judged, so git reports every
44
+ # problem it has with the directory rather than only the first.
45
+ #
46
+ # @return [Location, nil] nil when any command failed or answered empty
47
+ def location
48
+ prefix, prefix_ok = capture('rev-parse', '--show-prefix')
49
+ branch, branch_ok = capture('rev-parse', '--abbrev-ref', 'HEAD')
50
+ remote, remote_ok = capture('config', '--get', 'remote.origin.url')
51
+ return nil unless prefix_ok && branch_ok && remote_ok
52
+
53
+ here = Location.new(prefix: prefix, branch: branch, remote: remote)
54
+ here.complete? ? here : nil
55
+ end
56
+
57
+ # git is run without a shell, so a directory name is never split on its
58
+ # spaces nor read for metacharacters.
59
+ #
60
+ # @return [Array(String, Boolean)] the command's output and whether it succeeded
61
+ def capture(*args)
62
+ output = IO.popen(['git', '-C', @path, *args], &:read).chomp
63
+ [output, Process.last_status.success?]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Giturl
6
+ # Where a directory sits inside its repository: the path from the repository
7
+ # root down to it, the branch checked out, and the URL of the remote it came
8
+ # from. Immutable; built by GitDirectory from what git reports.
9
+ Location = Data.define(:prefix, :branch, :remote) do
10
+ # @return [Boolean] whether git answered with everything a URL needs.
11
+ # prefix is legitimately empty at the repository root, so it is not required.
12
+ def complete?
13
+ !branch.empty? && !remote.empty?
14
+ end
15
+
16
+ # @return [String] the branch in the form a URL path can carry, each segment
17
+ # percent-encoded but the separating slashes left intact
18
+ def encoded_branch
19
+ branch.split('/').map { |segment| ERB::Util.url_encode(segment) }.join('/')
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'giturl/version'
5
+
6
+ module Giturl
7
+ # The options giturl accepts on the command line, and the values a given
8
+ # command line puts in them.
9
+ class Options
10
+ def initialize
11
+ @values = {}
12
+ end
13
+
14
+ # Consumes the options from argv, leaving the target directories behind.
15
+ # `--app` names a browser, which only makes sense together with `--open`,
16
+ # so it turns `--open` on by itself.
17
+ #
18
+ # @param argv [Array<String>] the command line, modified in place
19
+ # @return [Hash] the values given, keyed by long option name
20
+ def parse!(argv)
21
+ OptionParser.new do |opts|
22
+ definitions(opts).parse!(argv, into: @values)
23
+ @values[:open] = true if @values.key?(:app)
24
+ end
25
+ @values
26
+ end
27
+
28
+ private
29
+
30
+ # @return [OptionParser] opts, with every giturl option declared on it
31
+ def definitions(opts)
32
+ opts.version = VERSION
33
+ opts.on('-o', '--open', 'Open the URL in your browser. default: no') { |v| v }
34
+ opts.on('-a [APPNAME]', '--app [APPNAME]', 'Specify a browser. i.e. "Safari.app"') { |v| v }
35
+ opts.on('-v', '--verbose', 'Verbose mode. default: no') { |v| v }
36
+ opts.on_tail('-h', '--help', 'Show this message') do
37
+ puts opts
38
+ exit
39
+ end
40
+ opts.on_tail('-V', '--version', 'Show version') do
41
+ puts opts.ver
42
+ exit
43
+ end
44
+ opts.banner = banner(opts)
45
+ opts
46
+ end
47
+
48
+ # @return [String] the description and usage shown above the option list
49
+ def banner(opts)
50
+ <<~BANNER
51
+
52
+ #{opts.ver}
53
+ #{DESCRIPTION}
54
+ Usage: #{opts.program_name} [options] [dirs]
55
+ [dirs]:
56
+ Target directories. Omit this when you only specify "."
57
+
58
+ [options]:
59
+ BANNER
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Giturl
4
+ # The URL of a git remote, as `remote.origin.url` reports it, and the https
5
+ # page that serves the same repository.
6
+ #
7
+ # A remote is written either as an scp-style address
8
+ # (git@github.com:owner/repo.git) or as a URL carrying a scheme
9
+ # (https://, ssh://, git://). Only the scp-style form can name a Host alias
10
+ # from ~/.ssh/config (git@github.com-work:owner/repo.git) in place of a real
11
+ # hostname, so that is the only form ssh is asked about.
12
+ class RemoteUrl
13
+ SCHEMED = %r{\A[a-z][a-z0-9+.-]*://(?:[^@/]*@)?(?<host>[^/:]+)(?::\d+)?/(?<path>.*)\z}i
14
+ SCP = %r{\A(?:[^@/]+@)?(?<host>[^/:]+):(?<path>.*)\z}
15
+
16
+ def initialize(raw)
17
+ @raw = raw
18
+ end
19
+
20
+ # Anything neither form recognizes is handed back untouched: it names no
21
+ # host, so there is no page to point at.
22
+ #
23
+ # @return [String] the https URL of the page this remote is served from
24
+ def https
25
+ schemed = SCHEMED.match(@raw)
26
+ return page(schemed[:host], schemed[:path]) if schemed
27
+
28
+ scp = SCP.match(@raw)
29
+ return @raw unless scp
30
+
31
+ page(hostname(scp[:host]) || scp[:host], scp[:path])
32
+ end
33
+
34
+ private
35
+
36
+ # @return [String] https://<host>/<path>, without the .git git clones carry
37
+ def page(host, path)
38
+ "https://#{host}/#{path.delete_suffix('.git')}"
39
+ end
40
+
41
+ # @param host_alias [String] a Host entry from ~/.ssh/config
42
+ # @return [String, nil] the HostName ssh resolves it to, nil if ssh says nothing
43
+ def hostname(host_alias)
44
+ output = IO.popen(['ssh', '-G', host_alias], err: File::NULL, &:read)
45
+ line = output.lines.find { |candidate| candidate =~ /^hostname / }
46
+ return nil unless line
47
+
48
+ line.split[1]
49
+ end
50
+ end
51
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Giturl
4
- VERSION = '1.3.6'
4
+ VERSION = '1.4.1'
5
5
  DESCRIPTION = <<~DESC
6
6
  giturl: A simple navigation tool for GitHub pages from local git-cloned directories.
7
7
 
data/lib/giturl.rb CHANGED
@@ -2,13 +2,16 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'giturl/version'
5
+ require 'giturl/git_directory'
5
6
 
6
7
  # converter module from path to url
7
8
  module Giturl
8
- require 'erb'
9
- # Main class of `Giturl` module.
9
+ # Published entry point of the `Giturl` module, kept as class methods because
10
+ # that is the interface released gems call:
10
11
  #
11
- # `self.url` is recommended to get URL like: Giturl.url('./lib')
12
+ # url = Giturl::Giturl.url('./lib')
13
+ #
14
+ # Nothing is decided here; every answer comes from a GitDirectory.
12
15
  class Giturl
13
16
  # Check if path is a git-managed directory and return the URL of the GitHub web page for that path
14
17
  #
@@ -23,12 +26,7 @@ module Giturl
23
26
  # @param path [String] path to check. Both relative and absolute forms are accepted.
24
27
  # @return [Boolean] git-managed directory or not.
25
28
  def self.git_managed?(path)
26
- stderr_old = $stderr.dup
27
- $stderr.reopen(File::NULL)
28
- inside = `git -C #{path} rev-parse --is-inside-work-tree`.chomp
29
- $stderr.flush
30
- $stderr.reopen stderr_old
31
- inside == 'true'
29
+ GitDirectory.new(path).managed?
32
30
  end
33
31
 
34
32
  # Convert path to git-managed directory to GitHub web page URL
@@ -36,53 +34,7 @@ module Giturl
36
34
  # @param path [String] path for a git-managed directory. Both relative and absolute forms are accepted.
37
35
  # @return [String] GitHub web page URL for the given git-managed directory
38
36
  def self.convert(path)
39
- gitdir_prefix, gitdir_branch, remote_origin_url, ok = git_location_info(path)
40
- unless ok
41
- warn 'Git commands failed. Please check if the directory is a git repository.'
42
- return ''
43
- end
44
- baseurl = construct_base_url(remote_origin_url)
45
- encoded_branch = gitdir_branch.split('/').map { |e| ERB::Util.url_encode(e) }.join('/')
46
- "#{baseurl}/tree/#{encoded_branch}/#{gitdir_prefix}"
47
- end
48
-
49
- # Run the git commands needed to build the URL, and report whether they succeeded.
50
- #
51
- # @param path [String] path for a git-managed directory
52
- # @return [Array(String, String, String, Boolean)] prefix, branch, remote origin URL, and
53
- # whether the info is usable. gitdir_prefix may legitimately be empty (path is the
54
- # repository root), so only command success and non-empty branch/remote are required.
55
- def self.git_location_info(path)
56
- prefix, prefix_ok = run_git(path, 'rev-parse', '--show-prefix')
57
- branch, branch_ok = run_git(path, 'rev-parse', '--abbrev-ref', 'HEAD')
58
- remote, remote_ok = run_git(path, 'config', '--get', 'remote.origin.url')
59
- ok = prefix_ok && branch_ok && remote_ok && !branch.empty? && !remote.empty?
60
- [prefix, branch, remote, ok]
61
- end
62
- private_class_method :git_location_info
63
-
64
- def self.run_git(path, *args)
65
- output = `git -C #{path} #{args.join(' ')}`.chomp
66
- [output, $?.success?] # rubocop:disable Style/SpecialGlobalVars
67
- end
68
- private_class_method :run_git
69
-
70
- def self.construct_base_url(remote_origin_url)
71
- baseurl = remote_origin_url
72
- if remote_origin_url =~ /@(.*?):/
73
- host_alias = ::Regexp.last_match(1)
74
- real_hostname = get_real_hostname(host_alias)
75
- baseurl = remote_origin_url.sub(host_alias, real_hostname) if real_hostname && real_hostname != host_alias
76
- end
77
- baseurl.tr(':', '/').gsub(/^.*@/, 'https://').gsub(/\.git$/, '')
78
- end
79
-
80
- def self.get_real_hostname(host_alias)
81
- ssh_output = `ssh -G #{host_alias} 2>#{File::NULL}`
82
- real_hostname_line = ssh_output.lines.find { |line| line =~ /^hostname / }
83
- return nil unless real_hostname_line
84
-
85
- real_hostname_line.split[1]
37
+ GitDirectory.new(path).url
86
38
  end
87
39
  end
88
40
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: giturl
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.6
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shinya Ohtani (shinyaohtani@github)
@@ -55,6 +55,10 @@ files:
55
55
  - giturl.gemspec
56
56
  - lib/command_giturl.rb
57
57
  - lib/giturl.rb
58
+ - lib/giturl/git_directory.rb
59
+ - lib/giturl/location.rb
60
+ - lib/giturl/options.rb
61
+ - lib/giturl/remote_url.rb
58
62
  - lib/giturl/version.rb
59
63
  homepage: https://github.com/shinyaohtani/giturl/blob/master/README.md
60
64
  licenses:
@@ -72,7 +76,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
72
76
  requirements:
73
77
  - - ">="
74
78
  - !ruby/object:Gem::Version
75
- version: 2.3.0
79
+ version: 3.2.0
76
80
  required_rubygems_version: !ruby/object:Gem::Requirement
77
81
  requirements:
78
82
  - - ">="