lib/gdk/project/git_worktree.rb (150 lines of code) (raw):
# frozen_string_literal: true
module GDK
module Project
class GitWorktree
DEFAULT_RETRY_ATTEMPTS = 0
NETWORK_RETRIES = 3
def initialize(project_name, worktree_path, default_branch, revision, auto_rebase: false)
@project_name = project_name
@worktree_path = worktree_path
@default_branch = default_branch
@revision = revision
@auto_rebase = auto_rebase
end
def update
stashed = stash_save
sh = execute_command(fetch_cmd, retry_attempts: NETWORK_RETRIES)
unless sh.success?
GDK::Output.puts(sh.read_stderr, stderr: true)
GDK::Output.error("Failed to fetch for '#{short_worktree_path}'", sh.read_stderr)
return false
end
result = auto_rebase? ? execute_rebase : execute_checkout_and_pull
ensure
stashed ? stash_pop : result
end
private
attr_reader :worktree_path, :default_branch, :revision, :auto_rebase
alias_method :auto_rebase?, :auto_rebase
def short_worktree_path
"#{worktree_path.basename}/"
end
def execute_command(command, **args)
args[:display_output] = false
args[:retry_attempts] ||= DEFAULT_RETRY_ATTEMPTS
Shellout.new(command, chdir: worktree_path).execute(**args)
end
def execute_rebase
current_branch_name.empty? ? checkout_revision : rebase
end
def execute_checkout_and_pull
checkout_revision && pull_ff_only
end
def checkout_revision(force: false)
checkout_flags = force ? '-f ' : ''
action = force ? 'forced checked out' : 'fetched and checked out'
sh = execute_command("git checkout #{checkout_flags}#{revision}")
if sh.success?
GDK::Output.success("Successfully #{action} '#{revision}' for '#{short_worktree_path}'")
true
else
GDK::Output.puts(sh.read_stderr, stderr: true)
GDK::Output.error("Failed to fetch and check out '#{revision}' for '#{short_worktree_path}'", sh.read_stderr)
false
end
end
def pull_ff_only
return true unless revision_is_default?
command = %w[git pull --ff-only]
command << remote_name << GDK.config.gitlab.default_branch if gitlab_repo?
sh = execute_command(command, retry_attempts: NETWORK_RETRIES)
if sh.success?
GDK::Output.success("Successfully pulled (--ff-only) for '#{short_worktree_path}'")
true
else
GDK::Output.puts(sh.read_stderr, stderr: true)
GDK::Output.error("Failed to pull (--ff-only) for for '#{short_worktree_path}'", sh.read_stderr)
false
end
end
def revision_is_default?
%w[master main].include?(revision)
end
def current_branch_name
@current_branch_name ||= execute_command('git branch --show-current').read_stdout
end
def stash_save
sh = execute_command('git stash save -u')
sh.success? && sh.read_stdout != 'No local changes to save'
end
def stash_pop
sh = execute_command('git stash pop')
if sh.success?
true
else
GDK::Output.puts(sh.read_stderr, stderr: true)
GDK::Output.error("Failed to run `git stash pop` for '#{short_worktree_path}', forcing a checkout to #{revision}. Changes are stored in `git stash`.",
sh.read_stderr, report_error: false)
checkout_revision(force: true)
false
end
end
def fetch_cmd
if gitlab_repo?
"git fetch --force --tags --prune #{remote_name} #{revision}"
elsif shallow_clone?
"git fetch --tags --depth 1 #{remote_name} #{revision}"
else
'git fetch --force --all --tags --prune'
end
end
def rebase
sh = execute_command("git rebase #{ref_remote_branch} -s recursive -X ours --no-rerere-autoupdate")
if sh.success?
GDK::Output.success("Successfully fetched and rebased '#{default_branch}' on '#{current_branch_name}' for '#{short_worktree_path}'")
true
else
GDK::Output.puts(sh.read_stderr, stderr: true)
GDK::Output.error("Failed to rebase '#{default_branch}' on '#{current_branch_name}' for '#{short_worktree_path}'", sh.read_stderr)
execute_command('git rebase --abort')
false # Always send false as the initial 'git rebase' failed.
end
end
def ref_remote_branch
sh = execute_command("git rev-parse --abbrev-ref #{default_branch}@{upstream}")
sh.success? ? sh.read_stdout : revision
end
def shallow_clone?
sh = execute_command("git rev-parse --is-shallow-repository")
sh.success? && sh.read_stdout.chomp == 'true'
end
def gitlab_repo?
worktree_path.to_s == GDK.config.gitlab.dir.to_s
end
# To avoid fetching from an unnamed remote, we need to determine the remote_name
# for the given revision. `origin` is usually the remote, but sometimes people
# modify `origin` to be something else.
#
# 1. If this revision is locally checked out (e.g. `master`), determine
# which remote is used via `git config branch.#{revision}.remote`.
#
# 2. If that doesn't exist, try to determine the remote by matching the URL configured in
# `GDK.config.repositories.<project>`.
#
# 3. If there is no match, just use `origin`.
def remote_name
@remote_name ||= begin
sh = execute_command(%W[git config branch.#{revision}.remote], display_error: false)
if sh.success?
sh.read_stdout.chomp
else
project_url = GDK.config.repositories.fetch(@project_name)
raise "Unknown project: #{@project_name}" unless project_url
sh = execute_command(%w[git remote -v])
raise 'Error running `git remote -v`' unless sh.success?
remotes = parse_git_remotes(sh.read_stdout.chomp)
remotes[project_url] || 'origin'
end
end
end
def parse_git_remotes(output)
# Sample output
# com git@gitlab.com:gitlab-community/gitlab-org/gitlab-shell.git (fetch)
# com git@gitlab.com:gitlab-community/gitlab-org/gitlab-shell.git (push)
# origin https://gitlab.com/gitlab-org/gitlab-shell.git (fetch)
# origin https://gitlab.com/gitlab-org/gitlab-shell.git (push)
lines = output.split("\n").select { |line| line.include?('(fetch)') }
lines.filter_map do |line|
remote_lines = line.split("\t")
next unless remote_lines.size >= 2
remote_name = remote_lines[0]
remote_url = remote_lines[1].split.first
[remote_url, remote_name] if remote_lines.size >= 2
end.to_h
end
end
end
end