4
\$\begingroup\$

Coming from Python, JavaScript and PHP, I'd like to learn to write Ruby in the way it's "supposed to". Well-written Python code is called "Pythonic", so I'd like to know how idiomatic my Ruby code is.

I've had great help from Rubocop, slapping my face if I didn't write properly, but I still feel some things can be done better.

Application

This set of scripts downloads a github/gitlab/bitbucket repository, removes the .git folder and moves it to the specified folder, so that the files are "de-git".

Some commands:

# Run tests (for regexes)
./degit.rb --test
# Extract repo to folder with repo name
./degit.rb zpqrtbnk/test-repo
# Extract tag/branch of repo to folder with repo name
./degit.rb zpqrtbnk/test-repo#temp
# Extract repo to specified folder
./degit.rb zpqrtbnk/test-repo some-folder

Code

Ruby v2.4.5
Idea came from degit by Rich Harris

degit.rb

#!/usr/bin/env ruby
require "tmpdir"
require_relative "repo"
require_relative "repo_type"
REPO_TYPES = {
 github: RepoType.new("github", "https://github.com", "github.com"),
 gitlab: RepoType.new("gitlab", "https://gitlab.com", "gitlab.com"),
 bitbucket: RepoType.new("bitbucket", "https://bitbucket.org", "bitbucket.org"),
 custom: RepoType.new("custom", :custom, :custom),
}.freeze
# TODO: Error handling
def main
 repo_name = ARGV[0]
 folder_name = ARGV[1]
 raise "Required parameter repo name not specified" if repo_name.nil?
 if repo_name == "--test"
 require_relative "tests"
 run_tests
 return
 end
 degit repo_name, folder_name
end
def temp_dir
 dir = Dir.mktmpdir("degit-", "/tmp")
 at_exit { FileUtils.remove_entry(dir) }
 dir
end
def degit(repo_name, folder_name)
 repo = Repo.new repo_name
 folder_name ||= repo.name
 dest_dir = File.join Dir.pwd, folder_name
 dir_exists = Dir.exist? dest_dir
 if dir_exists
 abort "Aborted" unless confirm_overwrite dest_dir
 end
 dir = temp_dir
 tmp_repo_path = File.join(dir, folder_name)
 cmd = repo.download_command tmp_repo_path
 puts `#{cmd}`
 FileUtils.remove_entry File.join(tmp_repo_path, ".git")
 FileUtils.remove_entry dest_dir if dir_exists
 FileUtils.mv(tmp_repo_path, Dir.pwd, force: true)
end
def confirm_overwrite(dest_dir)
 print "Destination folder #{dest_dir} already exists. Overwrite folder? [y/n] "
 # ARGV interferes with gets, so use STDIN.gets
 input = STDIN.gets.chomp.downcase
 return (input == "y") if %w[y n].include? input
 # Continue to ask until input is either y or n
 confirm_overwrite dest_dir
end
main if $PROGRAM_NAME == __FILE__

repo_type.rb

class RepoType
 attr_reader :name, :full_url
 def initialize(name, full_url, base_url, short_code=nil)
 @name = name
 @full_url = full_url
 @base_url = base_url
 @short_code = short_code || name.to_s.downcase
 end
 def id?(id)
 [@short_code, @base_url].include? id
 end
end

repo.rb

class Repo
 attr_reader :type, :tag, :name
 PREFIX_REGEX = %r{
 \A
 ((?<type>github|gitlab|bitbucket):)?
 (?<owner>[\w-]+)/(?<name>[\w-]+)
 (\#(?<tag>[\w\-\.]+))?
 \z
 }xi.freeze
 SSH_REGEX = %r{
 \A
 (?<source_url>
 git@(?<type>github\.com|gitlab\.com|bitbucket\.org):
 (?<owner>[\w-]+)/(?<name>[\w-]+)
 (\.git)?
 )
 (\#(?<tag>[\w\-\.]+))?
 \z
 }xi.freeze
 HTTPS_REGEX = %r{
 \A
 (?<source_url>
 https://(?<type>github\.com|gitlab\.com|bitbucket\.org)/
 (?<owner>[\w-]+)/(?<name>[\w-]+)
 )
 (\#(?<tag>[\w\-\.]+))?
 \z
 }xi.freeze
 def initialize(uri)
 @uri = uri
 raise "Required constant REPO_TYPES not defined" unless defined? REPO_TYPES
 parse_uri
 # debug unless @source_url.nil?
 end
 def valid_uri?
 @uri.end_with?(".git") || @uri.include?("/")
 end
 def parse_uri
 if @uri.end_with? ".git"
 @type = REPO_TYPES[:custom]
 return
 end
 repo = match_repo_info
 return nil if repo.nil?
 @owner = repo[:owner]
 @name = repo[:name]
 @tag = repo[:tag]
 @source_url = make_source_url repo
 end
 def match_repo_info
 [PREFIX_REGEX, SSH_REGEX, HTTPS_REGEX].each do |regex|
 repo_matches = regex.match @uri
 unless repo_matches.nil?
 @type = find_repo_type repo_matches[:type]
 return repo_matches
 end
 end
 nil
 end
 def find_repo_type(type)
 REPO_TYPES.each do |_, repo_type|
 return repo_type if repo_type.id? type
 end
 REPO_TYPES[:github]
 end
 def make_source_url(repo)
 return repo[:source_url] if repo.names.include? "source_url"
 source_url = @type.full_url || @uri
 "#{source_url}/#{@owner}/#{@name}"
 end
 def download_command(output_folder=nil)
 tag_spec = @tag.nil? ? "" : "--branch #{@tag}"
 parts = [
 "git clone --quiet --depth 1",
 tag_spec,
 @source_url,
 output_folder || @name,
 ]
 parts.join " "
 end
 def debug
 puts ""
 puts "source_url: #{@source_url}" unless @source_url.nil?
 puts "owner: #{@owner}" unless @owner.nil?
 puts "name: #{@name}" unless @name.nil?
 puts "tag: #{@tag}" unless @tag.nil?
 puts "download cmd: #{download_command}"
 end
end

tests.rb

VALID = %w[
 user1/repo1
 github:user2/repo2
 [email protected]:user3/repo3
 https://github.com/rmccue/test-repository
 gitlab:user5/repo5
 [email protected]:user6/repo6
 https://gitlab.com/user7/repo7
 bitbucket:user8/repo8
 [email protected]:user9/repo9
 https://bitbucket.org/user0/repo0
].freeze
INVALID = %w[
 http://github.com/user1/repo1
 https://github.com/user2
 https://github.comuser3/repo3
].freeze
WITH_TAG = %w[
 user1/repo1#dev
 user2/repo2#v1.2.3
 user3/repo3#1234abcd
].freeze
WITH_GIT_SUFFIX = %w[
 https://github.com/Rich-Harris/degit.git
 user@host:~/repos/website.nl.git
].freeze
def pf(str)
 print str
 $stdout.flush
end
def run_tests
 pf " VALID: "
 VALID.each do |r|
 pf "."
 repo = Repo.new r
 raise "#{r} isn't valid" if repo.type.nil?
 end
 puts ""
 pf " INVALID: "
 INVALID.each do |r|
 pf "."
 repo = Repo.new r
 raise "#{r} isn't invalid" unless repo.type.nil?
 end
 puts ""
 pf " WITH_TAG: "
 WITH_TAG.each do |r|
 pf "."
 repo = Repo.new r
 raise "#{r} isn't valid" if repo.type.nil?
 raise "#{r} has no tag" if repo.tag.nil?
 end
 puts ""
 pf "WITH_GIT_SUFFIX: "
 WITH_GIT_SUFFIX.each do |r|
 pf "."
 repo = Repo.new r
 raise "#{r} isn't valid" if repo.type.nil?
 end
 puts ""
end
asked Jan 31, 2019 at 15:28
\$\endgroup\$
0

1 Answer 1

2
\$\begingroup\$

Ruby does not have an import system like python, so variables and methods at the top level like REPO_TYPES and temp_dir are effectively global variables and methods.

Use modules to aggressively namespace, even for your main, especially when a small script begins to span more than one file:

module Degit
 def self.main # define singleton method
 end
end
Degit.main # call singleton method

This is also true for methods as well. def self.main in the example defines a singleton method on Degit itself. (Degit is a singleton in the sense that it will be the only instance of Module named "Degit", and main is a method it will now have).

Ruby classes operate in the same way:

class Foo
 class << self # opens singleton context
 def foo # also defines a singleton method
 end
 end
end

On another note, I feel like RepoType should either be:

  • completely removed and its responsibilities handled by Repo

Or

  • named Host and be more cohesive by owning variables and methods REPO_TYPE and find_repo_type within it, along with the regex definitions associated with each Host

Here's an example combining what I've outlined above:

class Host
 HOST_DEFN = {
 github: 'github.com',
 gitlab: 'gitlab.com',
 bitbucket: 'bitbucket.org',
 }
 attr_reader :name, :hostname
 def initialize(name, hostname)
 @name = name
 @hostname = hostname
 end
 def match?(uri)
 regexes.each_value.any? do |regex|
 regex.match?(uri)
 end
 end
 private
 def regexes
 {
 ssh: /ssh #{hostname} ssh/,
 https: /https #{hostname} https/,
 }
 end
 class << self
 def hosts
 @hosts ||= HOST_DEFN.map { |name, hostname| [name, new(name, hostname)] }.to_h
 end
 def matching_uri(uri)
 hosts.each_value.detect { |host| host.match?(uri) }
 end
 end
end
# usage
Host.hosts[:github] # => #<Host:0x00007fd95c48b5d8 @hostname="github.com", @name=:github>
uri = 'https gitlab.com https'
Host.matching_uri(uri) # => #<Host:0x00007fd95c462c00 @hostname="gitlab.com", @name=:gitlab>
answered Mar 3, 2019 at 9:28
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.