Files
brew/Library/Homebrew/livecheck/strategy/git.rb
T
Mike McQuaid 204ea7e964 Make tests use public APIs and enable their cops
- Make every statically-poked method public and call it directly,
  keeping `public_send` only for dynamically-named public methods.
- Read and write state through public `attr_*` accessors instead of
  instance variable reflection.
- Enable `Homebrew/NoSendInTests` and
  `Homebrew/NoInstanceVariableAccessInTests` now the test suite is
  clean, so neither pattern creeps back in.
- Keep rare justified disables, e.g. `Module#remove_const` is
  private core Ruby and raw memoisation state has no accessor.
2026-07-27 08:11:46 +01:00

230 lines
8.1 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])
class << self
sig { params(processed_urls: T::Hash[String, String]).void }
attr_writer :processed_urls
end
# The priority of the strategy on an informal scale of 1 to 10 (from
# lowest to highest).
PRIORITY = 8
# The regex used to extract tags from `git ls-remote --tags` output.
TAG_REGEX = %r{^\h+\s+refs/tags/(.+?)(?:\^{})?$}
# The default regex used to naively identify versions from tags when a
# regex isn't provided.
DEFAULT_REGEX = /\D*(.+)/
GITEA_INSTANCES = %w[
codeberg.org
gitea.com
opendev.org
tildegit.org
].freeze
private_constant :GITEA_INSTANCES
GOGS_INSTANCES = %w[
lolg.it
].freeze
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.blank?
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
# Runs `git ls-remote --tags` with the provided URL and returns a hash
# containing the `stdout` content or any errors from `stderr`.
#
# @param url [String] the URL of the Git repository to check
# @return [Hash]
sig { params(url: String).returns(T::Hash[Symbol, T.any(String, T::Array[String])]) }
def self.ls_remote_tags(url)
stdout, stderr, _status = system_command(
"git",
args: ["ls-remote", "--tags", "--end-of-options", url],
env: { "GIT_TERMINAL_PROMPT" => "0" },
print_stdout: false,
print_stderr: false,
debug: false,
verbose: false,
).to_a
data = {}
data[:content] = stdout.clone if stdout.present?
data[:messages] = stderr.split("\n") if stderr.present?
data
end
# Parse tags from `git ls-remote --tags` output.
#
# @param content [String] Git output to parse for tags
# @return [Array]
sig { params(content: String).returns(T::Array[String]) }
def self.tags_from_content(content)
content.scan(TAG_REGEX).flatten.uniq
end
# Identify versions from `git ls-remote --tags` output using a provided
# regex or the `DEFAULT_REGEX`. The regex is expected to use a capture
# group around the version text.
#
# @param content [String] the content to check
# @param regex [Regexp, nil] a regex for matching versions in content
# @return [Array]
sig {
params(
content: String,
regex: T.nilable(Regexp),
block: T.nilable(Proc),
).returns(T::Array[String])
}
def self.versions_from_content(content, regex = nil, &block)
tags = tags_from_content(content)
return [] if tags.empty?
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
match_regex = regex || DEFAULT_REGEX
tags.filter_map { |tag| tag[match_regex, 1] }.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 for matching versions in content
# @param content [String, nil] content to check instead of fetching
# @param options [Options] options to modify behavior
# @return [Hash]
sig {
override.params(
url: String,
regex: T.nilable(Regexp),
content: T.nilable(String),
options: Options,
block: T.nilable(Proc),
).returns(T::Hash[Symbol, T.anything])
}
def self.find_versions(url:, regex: nil, content: nil, options: Options.new, &block)
match_data = { matches: {}, regex:, url: }
match_data[:cached] = true if content
return match_data if url.blank?
unless match_data[:cached]
match_data.merge!(ls_remote_tags(url))
content = match_data[:content]
end
return match_data if content.blank?
versions_from_content(content, regex, &block).each do |match_text|
next unless match_text.is_a?(String)
match_data[:matches][match_text] = Version.new(match_text)
end
match_data
end
end
end
end
end