lib/git_simple.rb
require 'git_simple/version'
require 'git_simple/utils'
require 'git_simple/repository_helper'
require 'rugged'
require 'git_clone_url'
require 'grit'
require 'pathname'
# Simple interface for interacting with a Git repository.
#
# @example
#
# GitSimple('repo')
# .add('new_file')
# .rm('old_file')
# .commit('Made some changes', name: 'Art T. Fish', email: 'afish@example.com')
#
class GitSimple
class Error < StandardError; end
class MergeConflict < Error; end
class NoCommonCommit < Error; end
class PushError < Error; end
# Shortcut wrapper to clone the specified repository.
#
# @param (see GitSimple::Utils.process_clone_args)
#
# @return [GitSimple]
def self.clone(pathname_or_remote_url, *args)
local_pathname, options =
Utils.process_clone_args(pathname_or_remote_url, *args)
new(local_pathname).clone(pathname_or_remote_url, options)
end
# Shortcut wrapper to force clone the specified repository.
#
# @param (see GitSimple::Utils.process_clone_args)
#
# @return [GitSimple]
def self.clone_f(pathname_or_remote_url, *args)
local_pathname, options =
Utils.process_clone_args(pathname_or_remote_url, *args)
new(local_pathname).clone_f(pathname_or_remote_url, options)
end
# @param (see GitSimple::Utils.to_pathname) path to the working directory
# of the repository
def initialize(*args)
@pathname = Utils.to_pathname(*args)
end
# Initialize a new git repository with it working directory at @pathname.
#
# @return [GitSimple]
def init
Rugged::Repository.init_at(@pathname.to_s)
self
end
# @param [#realpath, #to_s] pathname_or_remote_url
# @param [Hash] options
# @option options [String] :username
# @option options [String] :password
# @option options [String] :ssh_passphrase
# @option options [Boolean] :force the clone even if the destination exists
#
# @return [GitSimple]
def clone(pathname_or_remote_url, options = {})
remote_url =
if pathname_or_remote_url.respond_to?(:realpath)
"file://#{pathname_or_remote_url.realpath}"
else
pathname_or_remote_url.to_s
end
@pathname.rmtree if options[:force] && @pathname.directory?
@pathname.mkpath
Rugged::Repository.clone_at(
remote_url,
@pathname.to_s,
Utils.build_remote_options(remote_url, options)
)
self
end
# Shortcut wrapper to force clone a repository.
# @param (see #clone)
# @return [GitSimple(see #clone)
def clone_f(pathname_or_remote_url, options = {})
clone(pathname_or_remote_url, options.merge(force: true))
end
# Add files into the index.
#
# @see https://github.com/hx/rugged-easy/blob/master/lib/rugged/easy/repository.rb
#
# @param [Array<String, Array<String>, Pathname>] *args
#
# @return [GitSimple]
def add(*args)
helper.glob_to_index(args) do |index, relative_path|
index.add(relative_path.to_s)
end
self
end
# Add all changes in the working tree into the index.
#
# @return [GitSimple]
def add_all
helper.index_write do |index|
index.add_all
index.update_all
end
self
end
# Remove files from the working tree and the index
#
# @param [Array<String, Array<String>, Pathname>] *args
#
# @return [GitSimple]
def rm(*args)
helper.glob_to_index(args) do |index, relative_path, realpath|
index.remove(relative_path.to_s)
realpath.delete
end
self
end
# @param [String] message
# @param [Hash] options
# @option options [String] :name for the author and committer
# @option options [String] :email for the author and committer
#
# @raise (see #commit_create)
#
# @return [GitSimple]
def commit(message, options = {})
helper.index_write do
helper.commit_create(
message,
rugged.index.write_tree,
helper.head_target,
options
)
end
self
end
# Merge the branch into the working directory.
#
# @param [Rugged::Branch] merge_branch
#
# @raise GitSimple::MergeConflict
# @raise GitSimple::NoCommonCommit
#
# @return [GitSimple]
def merge(merge_branch, options = {})
merge_analysis = rugged.merge_analysis(merge_branch.name)
if merge_analysis.include?(:fastforward)
rugged.references.update(helper.head_ref, merge_branch.target_id)
rugged.checkout_head(strategy: :force)
elsif merge_analysis.include?(:normal)
ours = helper.head_target
theirs = merge_branch.target
merge_base = rugged.merge_base(ours, theirs)
raise(NoCommonCommit) unless merge_base
base = rugged.rev_parse(merge_base)
index = ours.tree.merge(theirs.tree, base.tree)
commit_message =
if index.conflicts?
raise(MergeConflict) unless block_given?
message = yield(index, rugged, helper.working_directory)
raise(MergeConflict) unless message
index.conflict_cleanup
message
else
"Merge branch '#{helper.head_branch.name}' of #{helper.head_remote.url}"
end
helper.commit_create(
commit_message,
index.write_tree(rugged),
[ours, theirs],
options
)
rugged.checkout_head(strategy: :force)
end
self
end
# @param [Hash] options
# @option options [String] :name for the author and committer
# @option options [String] :email for the author and committer
# @option options [String] :username
# @option options [String] :password
# @option options [String] :ssh_passphrase
#
# @yieldparam [Rugged::Index] merge_index
# @yieldparam [Rugged] rugged
# @yieldparam [Pathname] working_directory
#
# @raise GitSimple::MergeConflict
# @raise GitSimple::NoCommonCommit
# @raise (see #commit_create)
#
# @return [GitSimple]
def pull(options = {}, &block)
return self unless helper.head_remote
helper.head_remote.fetch(
Utils.build_remote_options(helper.head_remote, options)
)
return self unless helper.head_remote_branch
unless helper.head_branch
rugged.checkout(helper.head_remote_branch)
return self
end
merge(helper.head_remote_branch, options, &block)
# FIXME: Until a rugged/libgit2 re-implementation of automatic garbage
# collection is created or available, the gc command which is built-in to
# standard git must be used. Instead of temporarily re-implmentig it the
# existing grit implementation can be used. Even through it is no longer
# maintained, it is good enough when it will be replaced soon.
#
# @see https://git-scm.com/docs/git-gc
# @see https://github.com/mojombo/grit/blob/5608567286e64a1c55c5e7fcd415364e04f8986e/lib/grit/repo.rb#L645
Grit::Repo.new(rugged.workdir).gc_auto
self
end
# @param [Hash] options
# @option options [String] :username
# @option options [String] :password
# @option options [String] :ssh_passphrase
#
# @return [GitSimple]
def push(options = {})
return self if rugged.empty?
return self unless helper.head_remote
helper.head_remote.push(
[helper.head_ref],
Utils.build_remote_options(helper.head_remote, options)
)
self
rescue Rugged::Error => exception
raise(PushError, exception.message)
end
# Allow direct access to the Rugged object.
#
# @yieldparam [Rugged] rugged
# @yieldparam [Pathname] working_directory
#
# @return [GitSimple]
def bypass
yield(rugged, helper.working_directory)
self
end
# @overload log
# Return the log of the entire repository
# @overload log(*args)
# Return the log for only the specified path
# @param [Pathname, String, Array<Pathname, String>] *args
#
# @return [Enumerable<Rugged::Commit>] which will iterate through all of the
# commits in the log sorted by date
def log(*args)
return [] unless helper.head_target
path = args.any? ? Utils.to_pathname(*args).to_s : nil
walker = Rugged::Walker.new(rugged)
walker.sorting(Rugged::SORT_DATE)
walker.push(helper.head_target)
walker.select { |x| path.nil? || x.diff(paths: [path]).size.nonzero? }
end
# @return [String]
def inspect # rubocop:disable Metrics/AbcSize
result = "Working directory: #{helper.working_directory}\n"
result << " HEAD: #{helper.head_ref}\n"
result << 'Remotes:'
if rugged.remotes.none?
result << " none\n"
else
result << "\n"
rugged.remotes.each { |x| result << " * #{x.name} #{x.url}\n" }
end
result << 'Branches:'
if rugged.branches.none?
result << " none\n"
else
result << "\n"
rugged.branches.each do |branch|
result << " * #{branch.name}"
result << " (upstream: #{branch.upstream.name})" if branch.upstream
result << "\n"
end
end
result
end
# @return [Array<String>] names of all the remotes in the repository
def remote_names
rugged.remotes.map(&:name)
end
# @return [Array<String>] names of all the branches in the repository.
def branch_names
rugged.branches.map(&:name)
end
# Is the working tree clean (e.g., everything has already been committed or
# dirty. (e.g., uncommitted changes exist)
#
# @return [Boolean]
def clean_working_tree?
rugged.status do |_file, status|
next if status.include?(:ignored)
return false
end
true
end
alias clean? clean_working_tree?
############################################################################
private
# @return [Rugged::Repository]
def rugged
@rugged ||= Rugged::Repository.discover(@pathname.to_s)
end
# @return [GitSimple::RepositoryHelper]
def helper
@helper ||= RepositoryHelper.new(rugged)
end
end
# Wrapper to make it easier to initialize a new GitSimple object.
# @param (see GitSimple#initialize)
# @return [GitSimple]
def GitSimple(*args) # rubocop:disable Naming/MethodName
GitSimple.new(*args)
end