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
  git@github.com:user3/repo3
  https://github.com/rmccue/test-repository

  gitlab:user5/repo5
  git@gitlab.com:user6/repo6
  https://gitlab.com/user7/repo7

  bitbucket:user8/repo8
  git@bitbucket.org: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
\$\endgroup\$
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>
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.