lib/release_tools/remote_repository.rb (190 lines of code) (raw):
# frozen_string_literal: true
module ReleaseTools
class RemoteRepository
include ::SemanticLogger::Loggable
OutOfSyncError = Class.new(StandardError)
class GitCommandError < StandardError
def initialize(message, output = nil)
message += "\n\n #{output.gsub("\n", "\n ")}" unless output.nil?
super(message)
end
end
CannotCheckoutBranchError = Class.new(GitCommandError)
CannotCloneError = Class.new(GitCommandError)
CannotCommitError = Class.new(GitCommandError)
CannotCreateTagError = Class.new(GitCommandError)
CannotPullError = Class.new(GitCommandError)
CannotPushError = Class.new(GitCommandError)
CanonicalRemote = Struct.new(:name, :url)
GitCommandResult = Struct.new(:output, :status)
def self.get(remotes, repository_name = nil, global_depth: 1, branch: nil)
repository_name ||= remotes
.values
.first
.split('/')
.last
.sub(/\.git\Z/, '')
new(
File.join(Dir.tmpdir, repository_name),
remotes,
global_depth: global_depth,
branch: branch
)
end
attr_reader :path, :remotes, :canonical_remote, :global_depth, :branch
def initialize(path, remotes, global_depth: 1, branch: nil)
logger.warn("Pushes will be ignored because of TEST env") if SharedStatus.dry_run?
@path = path
@global_depth = global_depth
@branch = branch
cleanup
# Add remotes, performing the first clone as necessary
self.remotes = remotes
end
def ensure_branch_exists(branch, base:)
fetch(branch)
checkout_branch(branch) || checkout_new_branch(branch, base: base)
end
def fetch(ref, remote: canonical_remote.name, depth: global_depth)
base_cmd = %w[fetch --quiet]
base_cmd << "--depth=#{depth}" if depth
base_cmd << remote.to_s
output, status = run_git([*base_cmd, "#{ref}:#{ref}"])
output, status = run_git([*base_cmd, ref]) unless status.success?
if status.success?
logger.info('Successfully fetched from repository', remote: remote, ref: ref, depth: depth)
else
logger.warn('Error when fetching from repository', output: output, remote: remote, ref: ref, depth: depth)
end
status.success?
end
def checkout_new_branch(branch, base:)
fetch(base)
output, status = run_git %W[checkout --quiet -b #{branch} #{base}]
status.success? || raise(CannotCheckoutBranchError.new(branch, output))
end
# Performs a merge on the repository.
#
# @param commits [String] the git reference to merge
# @param no_ff [true, false] make use of `--no-ff` git parameter
#
# @return [GitCommandResult] the result of the operation
def merge(commits, no_ff: false)
cmd = %w[merge --no-edit --no-log]
cmd << '--no-ff' if no_ff
cmd += Array(commits)
GitCommandResult.new(*run_git(cmd))
end
def pull(ref, remote: canonical_remote.name, depth: global_depth)
cmd = %w[pull --quiet]
cmd << "--depth=#{depth}" if depth
cmd << remote.to_s
cmd << ref
output, status = run_git(cmd)
if conflicts?
raise CannotPullError.new("Conflicts were found when pulling #{ref} from #{remote}", output)
end
status.success?
end
def pull_from_all_remotes(ref, depth: global_depth)
remotes.each_key do |remote_name|
pull(ref, remote: remote_name, depth: depth)
end
end
def push(remote, ref)
cmd = %W[push #{remote} #{ref}:#{ref}]
if SharedStatus.dry_run?
logger.trace(__method__, remote: remote, ref: ref)
return true
end
output = nil
status = nil
begin
Retriable.retriable(on: CannotPushError) do
output, status = run_git(cmd)
return status.success? if status.success? || Feature.disabled?(:retry_git_push)
logger.warn('Git command failed, retrying', cmd: cmd, output: output)
raise CannotPushError, "Git command failed: #{cmd}, Output: #{output}"
end
rescue CannotPushError
logger.error('Failed to push after retries', remote: remote, ref: ref, output: output, status: status)
false
end
end
def push_to_all_remotes(ref)
remotes.each_key.map do |remote_name|
push(remote_name, ref)
end
end
def cleanup
logger.trace(__method__, path: path) if Dir.exist?(path)
FileUtils.rm_rf(path, secure: true)
end
def self.run_git(args)
final_args = ['git', *args].join(' ')
logger.trace(__method__, command: final_args)
cmd_output = `#{final_args} 2>&1`
[cmd_output, $CHILD_STATUS]
end
private
# NOTE: This has been made private, as it's no longer used publicly, but the
# tests for other methods in this class currently depend on it.
def log(format: nil)
format_pattern =
case format
when :author
'%aN'
when :message
'%B'
end
cmd = %w[log --topo-order]
cmd << "--format='#{format_pattern}'" if format_pattern
output, = run_git(cmd)
output&.squeeze!("\n") if format_pattern == :message
output
end
# NOTE: This has been made private, as it's no longer used publicly, but the
# tests for other methods in this class currently depend on it.
def commit(files, no_edit: false, amend: false, message: nil, author: nil)
run_git ['add', *Array(files)] if files
cmd = %w[commit]
cmd << '--no-edit' if no_edit
cmd << '--amend' if amend
cmd << %[--author="#{author}"] if author
cmd += ['--message', %["#{message}"]] if message
output, status = run_git(cmd)
status.success? || raise(CannotCommitError.new(output))
end
# Given a Hash of remotes {name: url}, add each one to the repository
def remotes=(new_remotes)
@remotes = new_remotes.dup
@canonical_remote = CanonicalRemote.new(*remotes.first)
new_remotes.each do |remote_name, remote_url|
# Canonical remote doesn't need to be added twice
next if remote_name == canonical_remote.name
add_remote(remote_name, remote_url)
end
end
def add_remote(name, url)
_, status = run_git %W[remote add #{name} #{url}]
status.success?
end
def checkout_branch(branch)
output, status = run_git %W[checkout --quiet #{branch}]
logger.fatal('Failed to checkout', branch: branch, output: output) unless status.success?
status.success?
end
def in_path(&)
Dir.chdir(path, &)
end
def conflicts?
in_path do
output = `git ls-files -u`
return !output.empty?
end
end
def run_git(args)
ensure_repo_exist
in_path do
self.class.run_git(args)
end
end
def ensure_repo_exist
return if File.exist?(path) && File.directory?(File.join(path, '.git'))
cmd = %w[clone --quiet]
cmd << "--depth=#{global_depth}" if global_depth
cmd << "--branch=#{branch}" if branch
cmd << '--origin' << canonical_remote.name.to_s << canonical_remote.url << path
output, status = self.class.run_git(cmd)
unless status.success?
raise CannotCloneError.new("Failed to clone #{canonical_remote.url} to #{path}", output)
end
end
end
end