Skip to content

Commit 84dd40d

Browse files
committed
cmd/version-install: add new command.
This lets people install arbitrary versions by creating their own local non-Git tap if it doesn't already exist and puts formulae in there.
1 parent b3f1122 commit 84dd40d

File tree

15 files changed

+363
-20
lines changed

15 files changed

+363
-20
lines changed

Library/Homebrew/brew.sh

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -970,18 +970,15 @@ then
970970
export HOMEBREW_DEVELOPER_COMMAND="1"
971971
fi
972972

973-
if [[ -n "${HOMEBREW_DEVELOPER_COMMAND}" && -z "${HOMEBREW_DEVELOPER}" ]]
973+
if [[ -n "${HOMEBREW_DEVELOPER_COMMAND}" && -z "${HOMEBREW_DEVELOPER}" && -z "${HOMEBREW_DEV_CMD_RUN}" ]]
974974
then
975-
if [[ -z "${HOMEBREW_DEV_CMD_RUN}" ]]
976-
then
977-
opoo <<EOS
975+
opoo <<EOS
978976
$(bold "${HOMEBREW_COMMAND}") is a developer command, so Homebrew's
979977
developer mode has been automatically turned on.
980978
To turn developer mode off, run:
981979
brew developer off
982980
983981
EOS
984-
fi
985982

986983
git config --file="${HOMEBREW_GIT_CONFIG_FILE}" --replace-all homebrew.devcmdrun true 2>/dev/null
987984
export HOMEBREW_DEV_CMD_RUN="1"

Library/Homebrew/cask/cask_loader.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@ def load(config:)
215215
if ALLOWED_URL_SCHEMES.exclude?(url.scheme)
216216
raise UnsupportedInstallationMethod,
217217
"Non-checksummed download of #{name} formula file from an arbitrary URL is unsupported! " \
218-
"`brew extract` or `brew create` and `brew tap-new` to create a formula file in a tap " \
219-
"on GitHub instead."
218+
"`brew version-install` to install a formula file from your own custom tap " \
219+
"instead."
220220
end
221221

222222
begin
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "abstract_command"
5+
require "formula"
6+
require "formulary"
7+
require "tap"
8+
require "utils/github"
9+
require "utils/user"
10+
11+
module Homebrew
12+
module Cmd
13+
class VersionInstall < AbstractCommand
14+
DEFAULT_TAP_REPOSITORY = "versions"
15+
private_constant :DEFAULT_TAP_REPOSITORY
16+
17+
cmd_args do
18+
usage_banner "`version-install` <formula>[@<version>] [<version>]"
19+
description <<~EOS
20+
Extract a specific <version> of <formula> into a personal tap and install it.
21+
The default tap is <user>/#{DEFAULT_TAP_REPOSITORY}.
22+
<user> uses the GitHub username if available and the local username otherwise.
23+
EOS
24+
25+
named_args [:formula, :version], min: 1, max: 2
26+
end
27+
28+
sig { override.void }
29+
def run
30+
formula_input = args.named.fetch(0)
31+
version_input = args.named[1]
32+
33+
if version_input.nil? || formula_input.include?("@")
34+
unless formula_input.include?("@")
35+
raise UsageError, "Specify a version with <formula> <version> or <formula>@<version>."
36+
end
37+
38+
formula_base, _, version_from_input = formula_input.rpartition("@")
39+
odie "Invalid formula reference: #{formula_input}" if formula_base.empty? || version_from_input.empty?
40+
41+
version_input ||= version_from_input
42+
odie "Version mismatch: #{formula_input} != #{version_input}" if version_from_input != version_input
43+
44+
versioned_ref = formula_input
45+
formula_input = formula_base
46+
end
47+
48+
tap_with_name = Tap.with_formula_name(formula_input)
49+
tap, base_name = tap_with_name || [nil, formula_input]
50+
base_name = base_name.downcase
51+
.sub(/\b@(.*)\z\b/i, "")
52+
normalized_version = version_input.to_s
53+
.sub(/\D*(.+?)\D*$/, "\\1")
54+
.gsub(/\D+/, ".")
55+
versioned_name = "#{base_name}@#{normalized_version}"
56+
versioned_ref ||= if tap
57+
"#{tap}/#{versioned_name}"
58+
else
59+
versioned_name
60+
end
61+
62+
installed_formula_names = Formula.installed_formula_names
63+
if installed_formula_names.include?(versioned_name)
64+
ohai "#{versioned_name} is already installed"
65+
return
66+
end
67+
68+
existing_tap = Tap.installed
69+
.sort_by(&:name)
70+
.find { |tap| tap.formula_files_by_name.key?(versioned_name) }
71+
install_target = "#{existing_tap}/#{versioned_name}" if existing_tap
72+
73+
versioned_formula = begin
74+
Formulary.factory(versioned_ref, warn: false, prefer_stub: true)
75+
rescue TapFormulaAmbiguityError, FormulaUnavailableError, TapFormulaUnavailableError,
76+
TapFormulaUnreadableError
77+
nil
78+
end
79+
80+
if install_target.nil?
81+
install_target = if versioned_formula
82+
versioned_formula.full_name
83+
else
84+
current_formula = begin
85+
Formulary.factory(formula_input, warn: false, prefer_stub: true)
86+
rescue FormulaUnavailableError, TapFormulaUnavailableError, TapFormulaUnreadableError
87+
nil
88+
end
89+
90+
if current_formula && current_formula.version.to_s == version_input
91+
if installed_formula_names.include?(current_formula.name)
92+
ohai "#{current_formula.full_name} is already installed"
93+
return
94+
end
95+
96+
current_formula.full_name
97+
end
98+
end
99+
end
100+
101+
# Pretend we've run a dev command to avoid making it seem like the user
102+
# has done so manually.
103+
ENV["HOMEBREW_DEV_CMD_RUN"] = "1"
104+
105+
if install_target.nil?
106+
username = if !Homebrew::EnvConfig.no_github_api? && GitHub::API.credentials_type != :none
107+
begin
108+
GitHub.user["login"].presence
109+
rescue *GitHub::API::ERRORS
110+
nil
111+
end
112+
end
113+
username ||= User.current&.to_s
114+
username ||= ENV.fetch("USER")
115+
odie "Unable to determine a username for tap creation." if username.blank?
116+
117+
tap = Tap.fetch("#{username}/homebrew-#{DEFAULT_TAP_REPOSITORY}")
118+
unless tap.installed?
119+
ohai "Creating #{tap.name} tap for storing versioned formulae..."
120+
safe_system HOMEBREW_BREW_FILE, "tap-new", "--no-git", tap.name
121+
end
122+
123+
ohai "Extracting #{formula_input}@#{version_input} into #{tap.name}..."
124+
safe_system HOMEBREW_BREW_FILE, "extract", formula_input, tap.name, "--version=#{version_input}"
125+
126+
install_target = "#{tap}/#{versioned_name}"
127+
128+
opoo <<~EOS
129+
You are responsible for maintaining this #{install_target}!
130+
It will not receive any bugfix/security updates.
131+
Homebrew cannot support it for you because we cannot maintain every formula
132+
at every version or fix older versions in our Git history.
133+
EOS
134+
end
135+
136+
ohai "Installing #{install_target}..."
137+
safe_system HOMEBREW_BREW_FILE, "install", install_target
138+
end
139+
end
140+
end
141+
end

Library/Homebrew/formulary.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -693,8 +693,8 @@ def load_file(flags:, ignore_errors:)
693693
if ALLOWED_URL_SCHEMES.exclude?(url_scheme)
694694
raise UnsupportedInstallationMethod,
695695
"Non-checksummed download of #{name} formula file from an arbitrary URL is unsupported! " \
696-
"Use `brew extract` or `brew create` and `brew tap-new` to create a formula file in a tap " \
697-
"on GitHub instead."
696+
"Use `brew version-install` to install a formula file from your own custom tap " \
697+
"instead."
698698
end
699699
HOMEBREW_CACHE_FORMULA.mkpath
700700
FileUtils.rm_f(path)

Library/Homebrew/sorbet/rbi/dsl/homebrew/cmd/version_install.rbi

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# frozen_string_literal: true
2+
3+
require "cmd/shared_examples/args_parse"
4+
require "cmd/version-install"
5+
6+
RSpec.describe Homebrew::Cmd::VersionInstall do
7+
subject(:version_install) { described_class.new(args) }
8+
9+
let(:formulary_factory) { ->(ref, **_opts) { raise FormulaUnavailableError, ref } }
10+
let(:installed_taps) { [] }
11+
let(:installed_formula_names) { [] }
12+
let(:tap_name) { "tester/homebrew-versions" }
13+
let(:versioned_name) { "#{formula}@#{version}" }
14+
let(:args) { [formula, version] }
15+
let(:version) { "1.2" }
16+
let(:formula) { "foo" }
17+
18+
before do
19+
allow(Tap).to receive(:installed).and_return(installed_taps)
20+
allow(Formula).to receive(:installed_formula_names).and_return(installed_formula_names)
21+
allow(Homebrew::EnvConfig).to receive(:no_github_api?).and_return(true)
22+
allow(Formulary).to receive(:factory) { |ref, **opts| formulary_factory.call(ref, **opts) }
23+
end
24+
25+
it_behaves_like "parseable arguments"
26+
27+
context "when the versioned formula is already installed" do
28+
let(:installed_formula_names) { [versioned_name] }
29+
30+
it "skips installation" do
31+
expect(version_install).not_to receive(:safe_system)
32+
33+
version_install.run
34+
end
35+
end
36+
37+
context "when a tap already contains the versioned formula" do
38+
let(:existing_tap_name) { "alice/homebrew-versions" }
39+
let(:existing_tap) do
40+
instance_double(
41+
Tap,
42+
name: existing_tap_name,
43+
formula_files_by_name: { versioned_name => Pathname("/tmp/#{versioned_name}.rb") },
44+
)
45+
end
46+
let(:installed_taps) { [existing_tap] }
47+
let(:install_target) { "#{existing_tap_name}/#{versioned_name}" }
48+
49+
before do
50+
allow(existing_tap).to receive(:to_s).and_return(existing_tap_name)
51+
end
52+
53+
it "installs from the existing tap extraction" do
54+
expect(version_install).to receive(:safe_system)
55+
.with(HOMEBREW_BREW_FILE, "install", install_target).once
56+
57+
version_install.run
58+
end
59+
end
60+
61+
context "with formula@version input" do
62+
let(:args) { ["#{formula}@#{version}"] }
63+
let(:versioned_formula) { instance_double(Formula, full_name: "homebrew/core/#{versioned_name}") }
64+
let(:install_target) { "homebrew/core/#{versioned_name}" }
65+
let(:formulary_factory) do
66+
lambda do |ref, **_opts|
67+
return versioned_formula if ref == "#{formula}@#{version}"
68+
69+
raise FormulaUnavailableError, ref
70+
end
71+
end
72+
73+
it "installs a versioned formula that already exists" do
74+
expect(version_install).to receive(:safe_system)
75+
.with(HOMEBREW_BREW_FILE, "install", install_target).once
76+
77+
version_install.run
78+
end
79+
end
80+
81+
context "when the current formula matches the requested version" do
82+
let(:current_formula) { instance_double(Formula, full_name: "homebrew/core/#{formula}", name: formula, version:) }
83+
let(:install_target) { "homebrew/core/#{formula}" }
84+
let(:formulary_factory) do
85+
lambda do |ref, **_opts|
86+
return current_formula if ref == formula
87+
return raise FormulaUnavailableError, ref if ref == "#{formula}@#{version}"
88+
89+
raise "Unexpected ref: #{ref}"
90+
end
91+
end
92+
93+
it "installs the current formula" do
94+
expect(version_install).to receive(:safe_system)
95+
.with(HOMEBREW_BREW_FILE, "install", install_target).once
96+
97+
version_install.run
98+
end
99+
100+
context "when the current formula is already installed" do
101+
let(:installed_formula_names) { [formula] }
102+
103+
it "skips installation" do
104+
expect(version_install).not_to receive(:safe_system)
105+
106+
version_install.run
107+
end
108+
end
109+
end
110+
111+
context "when extracting into a tap" do
112+
let(:tap_installed) { false }
113+
let(:tap) { instance_double(Tap, name: tap_name, installed?: tap_installed) }
114+
115+
before do
116+
allow(User).to receive(:current).and_return("tester")
117+
allow(tap).to receive(:to_s).and_return(tap_name)
118+
allow(Tap).to receive(:fetch).with(tap_name).and_return(tap)
119+
end
120+
121+
it "extracts into a new tap when needed" do
122+
expect(version_install).to receive(:safe_system)
123+
.with(HOMEBREW_BREW_FILE, "tap-new", "--no-git", tap_name).ordered
124+
expect(version_install).to receive(:safe_system)
125+
.with(HOMEBREW_BREW_FILE, "extract", formula, tap_name, "--version=#{version}").ordered
126+
expect(version_install).to receive(:safe_system)
127+
.with(HOMEBREW_BREW_FILE, "install", "#{tap_name}/#{versioned_name}").ordered
128+
129+
version_install.run
130+
end
131+
132+
context "when the tap already exists" do
133+
let(:tap_installed) { true }
134+
135+
it "skips tap creation" do
136+
expect(version_install).to receive(:safe_system)
137+
.with(HOMEBREW_BREW_FILE, "extract", formula, tap_name, "--version=#{version}").ordered
138+
expect(version_install).to receive(:safe_system)
139+
.with(HOMEBREW_BREW_FILE, "install", "#{tap_name}/#{versioned_name}").ordered
140+
141+
version_install.run
142+
end
143+
end
144+
end
145+
end

completions/bash/brew

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3264,6 +3264,23 @@ _brew_verify() {
32643264
__brew_complete_formulae
32653265
}
32663266

3267+
_brew_version_install() {
3268+
local cur="${COMP_WORDS[COMP_CWORD]}"
3269+
case "${cur}" in
3270+
-*)
3271+
__brewcomp "
3272+
--debug
3273+
--help
3274+
--quiet
3275+
--verbose
3276+
"
3277+
return
3278+
;;
3279+
*) ;;
3280+
esac
3281+
__brew_complete_formulae
3282+
}
3283+
32673284
_brew_which_formula() {
32683285
local cur="${COMP_WORDS[COMP_CWORD]}"
32693286
case "${cur}" in
@@ -3473,6 +3490,7 @@ _brew() {
34733490
vendor-gems) _brew_vendor_gems ;;
34743491
vendor-install) _brew_vendor_install ;;
34753492
verify) _brew_verify ;;
3493+
version-install) _brew_version_install ;;
34763494
which-formula) _brew_which_formula ;;
34773495
which-update) _brew_which_update ;;
34783496
*) ;;

completions/fish/brew.fish

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2092,6 +2092,14 @@ __fish_brew_complete_arg 'verify' -l verbose -d 'Make some output more verbose'
20922092
__fish_brew_complete_arg 'verify' -a '(__fish_brew_suggest_formulae_all)'
20932093

20942094

2095+
__fish_brew_complete_cmd 'version-install' 'Extract a specific version of formula into a personal tap and install it'
2096+
__fish_brew_complete_arg 'version-install' -l debug -d 'Display any debugging information'
2097+
__fish_brew_complete_arg 'version-install' -l help -d 'Show this message'
2098+
__fish_brew_complete_arg 'version-install' -l quiet -d 'Make some output more quiet'
2099+
__fish_brew_complete_arg 'version-install' -l verbose -d 'Make some output more verbose'
2100+
__fish_brew_complete_arg 'version-install' -a '(__fish_brew_suggest_formulae_all)'
2101+
2102+
20952103
__fish_brew_complete_cmd 'which-formula' 'Show which formula(e) provides the given command'
20962104
__fish_brew_complete_arg 'which-formula' -l debug -d 'Display any debugging information'
20972105
__fish_brew_complete_arg 'which-formula' -l explain -d 'Output explanation of how to get command by installing one of the providing formulae'

completions/internal_commands_list.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,6 @@ uses
133133
vendor-gems
134134
vendor-install
135135
verify
136+
version-install
136137
which-formula
137138
which-update

0 commit comments

Comments
 (0)