Skip to content

Commit 126feee

Browse files
committed
formula: imply link_overwrite for related formulae
Treat Homebrew-owned conflicts from related formula families as implied `link_overwrite` paths when linking versioned or -full formulae. This restores the version-switching behavior that regressed for cases like installing `node@22` after uninstalling `node` left `npm` files behind in the prefix. While we're here, add some `AGENTS.md` changes based on feedback. References Homebrew/homebrew-core#271139
1 parent 9605dd1 commit 126feee

4 files changed

Lines changed: 245 additions & 40 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ When running commands in this repository, use `./bin/brew` (not a system `brew`
3131
- Write new code (using Sorbet `sig` type signatures and `typed: strict` for new files, but never for RSpec/test/`*_spec.rb` files)
3232
- Write new tests (avoid more than one `:integration_test` per file for speed).
3333
Write fast tests by preferring a single `expect` per unit test and combine expectations in a single test when it is an integration test or has non-trivial `before` for test setup.
34+
- When adding or tightening tests, verify them with a red/green cycle using the exact `--only=file:line` target for the example you changed.
35+
- Formula classes created in specs may be frozen; avoid stubbing class methods on them with RSpec mocks and prefer instance-level stubs or test setup that does not require class-method stubbing.
3436
- Keep comments minimal; prefer self-documenting code through strings, variable names, etc. over more comments.
3537

3638
## Repository Structure

Library/Homebrew/formula.rb

Lines changed: 74 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -628,23 +628,13 @@ def versioned_formula? = name.include?("@")
628628
def versioned_formulae_names
629629
name_prefix = unversioned_formula_name || name
630630

631-
versioned_names = if (formula_tap = tap)
632-
formula_tap.prefix_to_versioned_formulae_names.fetch(name_prefix, [])
633-
elsif path.exist?
634-
versioned_formula_glob = if name_prefix.end_with?("-full")
635-
"#{name_prefix.delete_suffix("-full")}@*-full.rb"
636-
else
637-
"#{name_prefix}@*.rb"
638-
end
639-
640-
Pathname.glob((path.dirname/versioned_formula_glob).to_s)
641-
.map { |path| path.basename(".rb").to_s }
642-
.sort
631+
versioned_formula_glob = if name_prefix.end_with?("-full")
632+
"#{name_prefix.delete_suffix("-full")}@*-full.rb"
643633
else
644-
raise "Either tap or path is required to list versioned formulae"
634+
"#{name_prefix}@*.rb"
645635
end
646636

647-
versioned_names.reject do |versioned_name|
637+
formula_names_for_glob(versioned_formula_glob).reject do |versioned_name|
648638
versioned_name == name
649639
end
650640
end
@@ -666,16 +656,31 @@ def unversioned_formula_name
666656
name.sub(/@[\d.]+(?=-full$|$)/, "")
667657
end
668658

659+
sig { params(glob: String).returns(T::Array[String]) }
660+
def formula_names_for_glob(glob)
661+
if (formula_tap = tap)
662+
tap_glob = File.basename(glob, ".rb")
663+
formula_tap.formula_names.select { |name| File.fnmatch?(tap_glob, name) }.sort
664+
elsif path.exist?
665+
Pathname.glob((path.dirname/glob).to_s)
666+
.map { |path| path.basename(".rb").to_s }
667+
.sort
668+
else
669+
raise "Either tap or path is required to list sibling formulae"
670+
end
671+
end
672+
private :formula_names_for_glob
673+
669674
# Returns the sibling `-full` or non-`-full` formula names for any Formula.
670675
sig { returns(T::Array[String]) }
671676
def full_formulae_names
672-
[
673-
if name.end_with?("-full")
674-
name.delete_suffix("-full")
675-
else
676-
"#{name}-full"
677-
end,
678-
]
677+
sibling_name = if name.end_with?("-full")
678+
name.delete_suffix("-full")
679+
else
680+
"#{name}-full"
681+
end
682+
683+
formula_names_for_glob("#{sibling_name}.rb")
679684
end
680685

681686
# Returns sibling `-full` or non-`-full` Formula objects for any Formula.
@@ -747,6 +752,38 @@ def link_overwrite_formulae
747752
end
748753
end
749754

755+
sig { params(path: Pathname).returns(T.nilable(T.any(String, Symbol))) }
756+
def link_overwrite_keg_name(path)
757+
# Don't overwrite files not created by Homebrew.
758+
return if path.stat.uid != HOMEBREW_ORIGINAL_BREW_FILE.stat.uid
759+
760+
keg = Keg.for(path)
761+
# This keg doesn't belong to any current core/tap formula, most likely coming from a DIY install.
762+
return if keg.tab.tap.nil?
763+
764+
keg.name
765+
rescue NotAKegError, Errno::ENOENT
766+
# File doesn't belong to any keg.
767+
:missing
768+
end
769+
770+
sig {
771+
params(keg_name: T.nilable(T.any(String, Symbol)), overwrite_formulae: T::Array[Formula]).returns(T::Boolean)
772+
}
773+
def implied_link_overwrite?(keg_name, overwrite_formulae)
774+
return false if overwrite_formulae.empty?
775+
return false if keg_name.nil?
776+
777+
case keg_name
778+
when :missing
779+
# File doesn't belong to any keg, so implied overwrites do not apply.
780+
false
781+
else
782+
overwrite_formulae.any? do |formula|
783+
formula.possible_names.include?(keg_name)
784+
end
785+
end
786+
end
750787
# Whether this {Formula} is version-synced with other formulae.
751788
sig { returns(T::Boolean) }
752789
def synced_with_other_formulae?
@@ -1599,39 +1636,37 @@ def skip_clean?(path)
15991636
end
16001637

16011638
# @see .link_overwrite
1639+
# Explicit `link_overwrite` paths may also be implied for related formula families.
16021640
sig { params(path: Pathname).returns(T::Boolean) }
16031641
def link_overwrite?(path)
1604-
# Don't overwrite files not created by Homebrew.
1605-
return false if path.stat.uid != HOMEBREW_ORIGINAL_BREW_FILE.stat.uid
1606-
1607-
# Don't overwrite files belong to other keg except when that
1642+
# Don't overwrite files that belong to another keg except when that
16081643
# keg's formula is deleted.
1609-
begin
1610-
keg = Keg.for(path)
1611-
rescue NotAKegError, Errno::ENOENT
1612-
# file doesn't belong to any keg.
1613-
else
1614-
tab_tap = keg.tab.tap
1615-
# this keg doesn't below to any core/tap formula, most likely coming from a DIY install.
1616-
return false if tab_tap.nil?
1617-
1644+
case keg_name = link_overwrite_keg_name(path)
1645+
when String
16181646
begin
1619-
f = Formulary.factory(keg.name)
1647+
f = Formulary.factory(keg_name)
16201648
rescue FormulaUnavailableError
16211649
# formula for this keg is deleted, so defer to allowlist
16221650
rescue TapFormulaAmbiguityError
16231651
return false # this keg belongs to another formula
16241652
else
16251653
# this keg belongs to another unrelated formula
1626-
return false unless f.possible_names.include?(keg.name)
1654+
return false unless f.possible_names.include?(keg_name)
16271655
end
1656+
when :missing
1657+
# File doesn't belong to any keg, so defer to overwrite checks below.
1658+
else
1659+
return false
16281660
end
1661+
16291662
to_check = path.relative_path_from(HOMEBREW_PREFIX).to_s
1630-
T.must(self.class.link_overwrite_paths).any? do |p|
1663+
return true if T.must(self.class.link_overwrite_paths).any? do |p|
16311664
p.to_s == to_check ||
1632-
to_check.start_with?("#{p.to_s.chomp("/")}/") ||
1633-
/^#{Regexp.escape(p.to_s).gsub('\*', ".*?")}$/.match?(to_check)
1665+
to_check.start_with?("#{p.to_s.chomp("/")}/") ||
1666+
/^#{Regexp.escape(p.to_s).gsub('\*', ".*?")}$/.match?(to_check)
16341667
end
1668+
1669+
implied_link_overwrite?(keg_name, link_overwrite_formulae)
16351670
end
16361671

16371672
# Whether this {Formula} is deprecated (i.e. warns on installation).

Library/Homebrew/test/cmd/link_spec.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@
66
RSpec.describe Homebrew::Cmd::Link do
77
it_behaves_like "parseable arguments"
88

9+
it "uses formula-aware conflict handling when linking a Formula" do
10+
formula = formula "testball" do
11+
url "foo-1.0"
12+
end
13+
keg = instance_double(Keg, rack: HOMEBREW_CELLAR/"testball", linked?: false, name: "testball")
14+
15+
cmd = described_class.new(["testball"])
16+
allow(cmd.args.named).to receive(:to_latest_kegs).and_return([keg])
17+
allow(Formulary).to receive(:keg_only?).with(keg.rack).and_return(false)
18+
allow(keg).to receive(:to_formula).and_return(formula)
19+
expect(Homebrew::Unlink).to receive(:unlink_link_overwrite_formulae).with(formula, verbose: false)
20+
allow(keg).to receive(:lock).and_yield
21+
expect(keg).to receive(:link).with(dry_run: false, verbose: false, overwrite: false).and_return(1)
22+
23+
expect { cmd.run }.to output(/Linking .*1 symlinks created\./).to_stdout
24+
end
25+
926
it "links a given Formula", :integration_test do
1027
setup_test_formula "testball", tab_attributes: { installed_on_request: true }
1128
Formula["testball"].any_installed_keg.unlink

Library/Homebrew/test/formula_spec.rb

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,9 +220,27 @@
220220
end
221221
end
222222

223-
it "returns sibling full and non-full names" do
223+
let(:f_versioned_full) do
224+
formula "foo@2.0-full" do
225+
url "foo-full-2.0"
226+
end
227+
end
228+
229+
before do
230+
[f, f_full, f_versioned].each do |formula|
231+
allow(formula).to receive(:tap).and_return(nil)
232+
FileUtils.touch formula.path
233+
end
234+
end
235+
236+
it "returns only existing sibling full and non-full names" do
224237
expect(f.full_formulae_names).to eq ["foo-full"]
225238
expect(f_full.full_formulae_names).to eq ["foo"]
239+
expect(f_versioned.full_formulae_names).to eq []
240+
241+
allow(f_versioned_full).to receive(:tap).and_return(nil)
242+
FileUtils.touch f_versioned_full.path
243+
226244
expect(f_versioned.full_formulae_names).to eq ["foo@2.0-full"]
227245
end
228246
end
@@ -340,6 +358,139 @@
340358
end
341359
end
342360

361+
describe "#link_overwrite?" do
362+
let(:versioned_formula) do
363+
formula "foo@22" do
364+
url "foo-22.0"
365+
end
366+
end
367+
368+
let(:related_formula) do
369+
formula "foo" do
370+
url "foo-1.0"
371+
end
372+
end
373+
374+
let(:conflict_file) { HOMEBREW_PREFIX/"lib/node_modules/npm/LICENSE" }
375+
376+
before do
377+
allow(versioned_formula).to receive(:link_overwrite_formulae).and_return([related_formula])
378+
conflict_file.dirname.mkpath
379+
FileUtils.touch conflict_file
380+
end
381+
382+
after do
383+
FileUtils.rm_f conflict_file
384+
conflict_file.dirname.rmdir_if_possible
385+
conflict_file.dirname.parent.rmdir_if_possible
386+
conflict_file.dirname.parent.parent.rmdir_if_possible
387+
end
388+
389+
it "does not allow untracked conflicts for related formula families" do
390+
expect(versioned_formula.link_overwrite?(conflict_file)).to be false
391+
end
392+
393+
it "returns false when the conflict is not Homebrew-managed" do
394+
allow(versioned_formula).to receive(:link_overwrite_keg_name).and_return(nil)
395+
396+
expect(versioned_formula.link_overwrite?(HOMEBREW_PREFIX/"bin/foo")).to be false
397+
end
398+
399+
it "returns false for ambiguous keg names" do
400+
allow(versioned_formula).to receive(:link_overwrite_keg_name).and_return("foo")
401+
ambiguity_loaders = [
402+
instance_double(Formulary::FormulaLoader, tap: instance_double(Tap, to_s: "homebrew/core")),
403+
instance_double(Formulary::FormulaLoader, tap: instance_double(Tap, to_s: "homebrew/other")),
404+
]
405+
allow(Formulary).to receive(:factory).with("foo")
406+
.and_raise(TapFormulaAmbiguityError.new("foo", ambiguity_loaders))
407+
408+
expect(versioned_formula.link_overwrite?(HOMEBREW_PREFIX/"bin/foo")).to be false
409+
end
410+
411+
it "returns false for unrelated keg names" do
412+
unrelated_formula = formula "bar" do
413+
url "bar-1.0"
414+
end
415+
allow(versioned_formula).to receive(:link_overwrite_keg_name).and_return("bar")
416+
allow(Formulary).to receive(:factory).with("bar").and_return(unrelated_formula)
417+
allow(unrelated_formula).to receive(:possible_names).and_return(["baz"])
418+
419+
expect(versioned_formula.link_overwrite?(HOMEBREW_PREFIX/"bin/bar")).to be false
420+
end
421+
422+
it "allows explicit link_overwrite paths" do
423+
formula_with_explicit_overwrite = formula "baz" do
424+
url "baz-1.0"
425+
link_overwrite "bin/baz"
426+
end
427+
allow(formula_with_explicit_overwrite).to receive(:link_overwrite_keg_name).and_return("baz")
428+
allow(Formulary).to receive(:factory).with("baz").and_return(formula_with_explicit_overwrite)
429+
430+
expect(formula_with_explicit_overwrite.link_overwrite?(HOMEBREW_PREFIX/"bin/baz")).to be true
431+
end
432+
433+
it "allows deleted related keg names through implied overwrites" do
434+
allow(versioned_formula).to receive(:link_overwrite_keg_name).and_return("foo-old")
435+
allow(Formulary).to receive(:factory).with("foo-old").and_raise(FormulaUnavailableError.new("foo-old"))
436+
allow(related_formula).to receive_messages(oldnames: ["foo-old"], aliases: [])
437+
438+
expect(versioned_formula.link_overwrite?(HOMEBREW_PREFIX/"bin/foo")).to be true
439+
end
440+
441+
it "returns false for missing conflicts without explicit or implied overwrites" do
442+
formula_without_overwrites = formula "qux" do
443+
url "qux-1.0"
444+
end
445+
allow(formula_without_overwrites).to receive_messages(link_overwrite_keg_name: :missing,
446+
link_overwrite_formulae: [])
447+
448+
expect(formula_without_overwrites.link_overwrite?(HOMEBREW_PREFIX/"bin/qux")).to be false
449+
end
450+
end
451+
452+
describe "#implied_link_overwrite?" do
453+
let(:versioned_formula) do
454+
formula "foo@22" do
455+
url "foo-22.0"
456+
end
457+
end
458+
459+
let(:related_formula) do
460+
formula "foo" do
461+
url "foo-1.0"
462+
end
463+
end
464+
465+
before do
466+
allow(related_formula).to receive_messages(oldnames: ["foo-old"], aliases: ["foo-alias"])
467+
end
468+
469+
it "does not allow missing conflicts without actual related formulae" do
470+
expect(versioned_formula.implied_link_overwrite?(:missing, [])).to be false
471+
end
472+
473+
it "does not allow non-Homebrew conflicts" do
474+
expect(versioned_formula.implied_link_overwrite?(nil, [related_formula])).to be false
475+
end
476+
477+
it "does not allow missing conflicts even when related formulae exist" do
478+
expect(versioned_formula.implied_link_overwrite?(:missing, [related_formula])).to be false
479+
end
480+
481+
it "allows related keg names via oldnames" do
482+
expect(versioned_formula.implied_link_overwrite?("foo-old", [related_formula])).to be true
483+
end
484+
485+
it "allows related keg names via aliases" do
486+
expect(versioned_formula.implied_link_overwrite?("foo-alias", [related_formula])).to be true
487+
end
488+
489+
it "does not allow unrelated keg names" do
490+
expect(versioned_formula.implied_link_overwrite?("bar", [related_formula])).to be false
491+
end
492+
end
493+
343494
example "installed alias with core" do
344495
f = formula do
345496
url "foo-1.0"

0 commit comments

Comments
 (0)