224 lines
8.0 KiB
Ruby
224 lines
8.0 KiB
Ruby
# typed: strict
|
|
# frozen_string_literal: true
|
|
|
|
require "addressable"
|
|
require "livecheck/strategic"
|
|
require "system_command"
|
|
|
|
module Homebrew
|
|
module Livecheck
|
|
module Strategy
|
|
# The {Git} strategy identifies versions of software in a Git repository
|
|
# by checking the tags using `git ls-remote --tags`.
|
|
#
|
|
# Livecheck has historically prioritized the {Git} strategy over others
|
|
# and this behavior was continued when the priority setup was created.
|
|
# This is partly related to Livecheck checking formula URLs in order of
|
|
# `head`, `stable` and then `homepage`. The higher priority here may
|
|
# be removed (or altered) in the future if we reevaluate this particular
|
|
# behavior.
|
|
#
|
|
# This strategy does not have a default regex. Instead, it simply removes
|
|
# any non-digit text from the start of tags and parses the rest as a
|
|
# {Version}. This works for some simple situations but even one unusual
|
|
# tag can cause a bad result. It's better to provide a regex in a
|
|
# `livecheck` block, so `livecheck` only matches what we really want.
|
|
#
|
|
# @api public
|
|
class Git
|
|
extend Strategic
|
|
extend SystemCommand::Mixin
|
|
|
|
# Used to cache processed URLs, to avoid duplicating effort.
|
|
@processed_urls = T.let({}, T::Hash[String, String])
|
|
|
|
# The priority of the strategy on an informal scale of 1 to 10 (from
|
|
# lowest to highest).
|
|
PRIORITY = 8
|
|
|
|
# The default regex used to naively identify versions from tags when a
|
|
# regex isn't provided.
|
|
DEFAULT_REGEX = /\D*(.+)/
|
|
|
|
GITEA_INSTANCES = T.let(%w[
|
|
codeberg.org
|
|
gitea.com
|
|
opendev.org
|
|
tildegit.org
|
|
].freeze, T::Array[String])
|
|
private_constant :GITEA_INSTANCES
|
|
|
|
GOGS_INSTANCES = T.let(%w[
|
|
lolg.it
|
|
].freeze, T::Array[String])
|
|
private_constant :GOGS_INSTANCES
|
|
|
|
# Processes and returns the URL used by livecheck.
|
|
sig { params(url: String).returns(String) }
|
|
def self.preprocess_url(url)
|
|
processed_url = @processed_urls[url]
|
|
return processed_url if processed_url
|
|
|
|
begin
|
|
uri = Addressable::URI.parse url
|
|
rescue Addressable::URI::InvalidURIError
|
|
return url
|
|
end
|
|
|
|
host = uri.host
|
|
path = uri.path
|
|
return url if host.nil? || path.nil?
|
|
|
|
host = "github.com" if host == "github.s3.amazonaws.com"
|
|
path = path.delete_prefix("/").delete_suffix(".git")
|
|
scheme = uri.scheme
|
|
|
|
if host == "github.com"
|
|
return url if path.match? %r{/releases/latest/?$}
|
|
|
|
owner, repo = path.delete_prefix("downloads/").split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
|
|
elsif GITEA_INSTANCES.include?(host)
|
|
return url if path.match? %r{/releases/latest/?$}
|
|
|
|
owner, repo = path.split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
|
|
elsif GOGS_INSTANCES.include?(host)
|
|
owner, repo = path.split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}.git"
|
|
# sourcehut
|
|
elsif host == "git.sr.ht"
|
|
owner, repo = path.split("/")
|
|
processed_url = "#{scheme}://#{host}/#{owner}/#{repo}"
|
|
# GitLab (gitlab.com or self-hosted)
|
|
elsif path.include?("/-/archive/")
|
|
processed_url = url.sub(%r{/-/archive/.*$}i, ".git")
|
|
end
|
|
|
|
if processed_url && (processed_url != url)
|
|
@processed_urls[url] = processed_url
|
|
else
|
|
url
|
|
end
|
|
end
|
|
|
|
# Whether the strategy can be applied to the provided URL.
|
|
#
|
|
# @param url [String] the URL to match against
|
|
# @return [Boolean]
|
|
sig { override.params(url: String).returns(T::Boolean) }
|
|
def self.match?(url)
|
|
url = preprocess_url(url)
|
|
(DownloadStrategyDetector.detect(url) <= GitDownloadStrategy) == true
|
|
end
|
|
|
|
# Fetches a remote Git repository's tags using `git ls-remote --tags`
|
|
# and parses the command's output. If a regex is provided, it will be
|
|
# used to filter out any tags that don't match it.
|
|
#
|
|
# @param url [String] the URL of the Git repository to check
|
|
# @param regex [Regexp] the regex to use for filtering tags
|
|
# @return [Hash]
|
|
sig { params(url: String, regex: T.nilable(Regexp)).returns(T::Hash[Symbol, T.untyped]) }
|
|
def self.tag_info(url, regex = nil)
|
|
stdout, stderr, _status = system_command(
|
|
"git",
|
|
args: ["ls-remote", "--tags", url],
|
|
env: { "GIT_TERMINAL_PROMPT" => "0" },
|
|
print_stdout: false,
|
|
print_stderr: false,
|
|
debug: false,
|
|
verbose: false,
|
|
).to_a
|
|
|
|
tags_data = { tags: T.let([], T::Array[String]) }
|
|
tags_data[:messages] = stderr.split("\n") if stderr.present?
|
|
return tags_data if stdout.blank?
|
|
|
|
# Isolate tag strings and filter by regex
|
|
tags = stdout.gsub(%r{^.*\trefs/tags/|\^{}$}, "").split("\n").uniq.sort
|
|
tags.select! { |t| regex.match?(t) } if regex
|
|
tags_data[:tags] = tags
|
|
|
|
tags_data
|
|
end
|
|
|
|
# Identify versions from tag strings using a provided regex or the
|
|
# `DEFAULT_REGEX`. The regex is expected to use a capture group around
|
|
# the version text.
|
|
#
|
|
# @param tags [Array] the tags to identify versions from
|
|
# @param regex [Regexp, nil] a regex to identify versions
|
|
# @return [Array]
|
|
sig {
|
|
params(
|
|
tags: T::Array[String],
|
|
regex: T.nilable(Regexp),
|
|
block: T.nilable(Proc),
|
|
).returns(T::Array[String])
|
|
}
|
|
def self.versions_from_tags(tags, regex = nil, &block)
|
|
if block
|
|
block_return_value = if regex.present?
|
|
yield(tags, regex)
|
|
elsif block.arity == 2
|
|
yield(tags, DEFAULT_REGEX)
|
|
else
|
|
yield(tags)
|
|
end
|
|
return Strategy.handle_block_return(block_return_value)
|
|
end
|
|
|
|
tags.filter_map do |tag|
|
|
if regex
|
|
# Use the first capture group (the version)
|
|
# This code is not typesafe unless the regex includes a capture group
|
|
T.unsafe(tag.scan(regex).first)&.first
|
|
else
|
|
# Remove non-digits from the start of the tag and use that as the
|
|
# version text
|
|
tag[DEFAULT_REGEX, 1]
|
|
end
|
|
end.uniq
|
|
end
|
|
|
|
# Checks the Git tags for new versions. When a regex isn't provided,
|
|
# this strategy simply removes non-digits from the start of tag
|
|
# strings and parses the remaining text as a {Version}.
|
|
#
|
|
# @param url [String] the URL of the Git repository to check
|
|
# @param regex [Regexp, nil] a regex used for matching versions
|
|
# @param options [Options] options to modify behavior
|
|
# @return [Hash]
|
|
sig {
|
|
override(allow_incompatible: true).params(
|
|
url: String,
|
|
regex: T.nilable(Regexp),
|
|
options: Options,
|
|
block: T.nilable(Proc),
|
|
).returns(T::Hash[Symbol, T.anything])
|
|
}
|
|
def self.find_versions(url:, regex: nil, options: Options.new, &block)
|
|
match_data = { matches: {}, regex:, url: }
|
|
|
|
tags_data = tag_info(url, regex)
|
|
tags = tags_data[:tags]
|
|
|
|
if tags_data.key?(:messages)
|
|
match_data[:messages] = tags_data[:messages]
|
|
return match_data if tags.blank?
|
|
end
|
|
|
|
versions_from_tags(tags, regex, &block).each do |version_text|
|
|
match_data[:matches][version_text] = Version.new(version_text)
|
|
rescue TypeError
|
|
next
|
|
end
|
|
|
|
match_data
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|