Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 73 additions & 9 deletions Library/Homebrew/cask/upgrade.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,8 @@ def self.upgrade_casks!(
end
end

upgradable_casks = outdated_casks.map do |c|
unless c.installed?
odie <<~EOS
The cask '#{c.token}' was affected by a bug and cannot be upgraded as-is. To fix this, run:
brew reinstall --cask --force #{c.token}
EOS
end

[CaskLoader.load(c.installed_caskfile), c]
upgradable_casks = outdated_casks.filter_map do |c|
load_old_cask_for_upgrade(c, explicit: casks.present?)&.then { |old_cask| [old_cask, c] }
end

return false if upgradable_casks.empty?
Expand Down Expand Up @@ -301,5 +294,76 @@ def self.upgrade_cask(
end_time = Time.now
Homebrew.messages.package_installed(new_cask.token, end_time - start_time)
end

sig { params(cask: Cask, explicit: T::Boolean).returns(T.nilable(Cask)) }
def self.load_old_cask_for_upgrade(cask, explicit:)
odie cannot_upgrade_as_is_message(cask) unless cask.installed?

installed_caskfile = cask.installed_caskfile
odie cannot_upgrade_as_is_message(cask) unless installed_caskfile

CaskLoader.load_from_installed_caskfile(installed_caskfile)
rescue CaskUnreadableError, CaskInvalidError, CaskUnavailableError => e
if (recovered_cask = recover_old_cask_from_current_definition(cask))
opoo <<~EOS
Recovered unreadable installed metadata for #{cask.token}; using the current cask definition for upgrade.
#{e}
EOS

return recovered_cask
end

message = unreadable_installed_metadata_message(cask, e)

odie message if explicit

opoo <<~EOS
Skipping #{cask.token}.
#{message}
EOS

nil
end

sig { params(cask: Cask).returns(T.nilable(Cask)) }
def self.recover_old_cask_from_current_definition(cask)
installed_version = cask.installed_version.to_s
return if installed_version.blank?

recovered_cask = CaskLoader.load(cask.full_name, config: cask.config)
recovered_cask.allow_reassignment = true
recovered_cask.version(installed_version)
recovered_cask
rescue CaskUnavailableError, CaskInvalidError, CaskUnreadableError
nil
ensure
recovered_cask&.allow_reassignment = false
end

sig { params(cask: Cask).returns(String) }
def self.cannot_upgrade_as_is_message(cask)
<<~EOS
The cask '#{cask.token}' was affected by a bug and cannot be upgraded as-is.
#{reinstall_instructions(cask)}
EOS
end

sig { params(cask: Cask, error: Exception).returns(String) }
def self.unreadable_installed_metadata_message(cask, error)
<<~EOS
The cask '#{cask.token}' cannot be upgraded because its installed metadata is unreadable.
#{error}

#{reinstall_instructions(cask)}
EOS
end

sig { params(cask: Cask).returns(String) }
def self.reinstall_instructions(cask)
<<~EOS
To fix this, run:
brew reinstall --cask --force #{cask.token}
EOS
end
end
end
144 changes: 144 additions & 0 deletions Library/Homebrew/test/cask/upgrade_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,116 @@ def write_info_plist(path, short_version:, bundle_version:)
end

describe "without --greedy" do
it "recovers unreadable metadata for auto-updating casks during bulk upgrade" do
auto_updates.installed_caskfile.write <<~RUBY
cask "auto-updates" do
on_el_capitan do
end
end
RUBY

# Simulate app self-update to a newer version than metadata, but still behind tap.
write_info_plist(auto_updates_path, short_version: "2.60", bundle_version: "2060")

expect(described_class).not_to receive(:upgrade_cask)
expect(described_class).to receive(:show_upgrade_summary) do |cask_upgrades, dry_run:|
expect(dry_run).to be(true)
expect(cask_upgrades).to include(
"local-caffeine 1.2.2 -> 1.2.3",
"local-transmission-zip 2.60 -> 2.61",
"auto-updates 2.57 -> 2.61",
"renamed-app 1.0.0 -> 2.0.0",
)
end

expect do
described_class.upgrade_casks!(dry_run: true, args:)
end.to output(/Recovered unreadable installed metadata for auto-updates/).to_stderr
end

it "recovers casks with unreadable installed metadata when upgrading all casks" do
local_caffeine.installed_caskfile.write <<~RUBY
cask "local-caffeine" do
on_el_capitan do
end
end
RUBY

expect(described_class).not_to receive(:upgrade_cask)
expect(described_class).to receive(:show_upgrade_summary) do |cask_upgrades, dry_run:|
expect(dry_run).to be(true)
expect(cask_upgrades).to include(
"local-caffeine 1.2.2 -> 1.2.3",
"local-transmission-zip 2.60 -> 2.61",
"auto-updates 2.57 -> 2.61",
"renamed-app 1.0.0 -> 2.0.0",
)
end

expect do
described_class.upgrade_casks!(dry_run: true, args:)
end.to output(/Recovered unreadable installed metadata for local-caffeine/).to_stderr
end

it "recovers explicit casks with unreadable installed metadata" do
unreadable = Cask::CaskUnreadableError.new("local-caffeine", "undefined method 'on_el_capitan'")

allow(described_class).to receive(:outdated_casks).and_return([local_caffeine])
allow(Cask::CaskLoader).to receive(:load_from_installed_caskfile).and_call_original
allow(Cask::CaskLoader).to receive(:load_from_installed_caskfile)
.with(local_caffeine.installed_caskfile)
.and_raise(unreadable)

expect(described_class).not_to receive(:upgrade_cask)
expect(described_class).to receive(:show_upgrade_summary)
.with(["local-caffeine 1.2.2 -> 1.2.3"], dry_run: true)
expect do
described_class.upgrade_casks!(local_caffeine, dry_run: true, args:)
end.to output(/Recovered unreadable installed metadata for local-caffeine/).to_stderr
end

it "skips non-explicit casks when metadata is unreadable and cannot be recovered" do
unreadable = Cask::CaskUnreadableError.new("local-caffeine", "undefined method 'on_el_capitan'")

allow(described_class).to receive(:outdated_casks).and_return([local_caffeine, local_transmission])
allow(Cask::CaskLoader).to receive(:load_from_installed_caskfile).and_call_original
allow(Cask::CaskLoader).to receive(:load_from_installed_caskfile)
.with(local_caffeine.installed_caskfile)
.and_raise(unreadable)
allow(described_class).to receive(:recover_old_cask_from_current_definition).and_call_original
allow(described_class).to receive(:recover_old_cask_from_current_definition)
.with(local_caffeine)
.and_return(nil)

expect(described_class).not_to receive(:upgrade_cask)
expect(described_class).to receive(:show_upgrade_summary)
.with(["local-transmission-zip 2.60 -> 2.61"], dry_run: true)

expect do
described_class.upgrade_casks!(dry_run: true, args:)
end.to output(/Skipping local-caffeine\./).to_stderr
end

it "fails for explicit casks when metadata is unreadable and cannot be recovered" do
unreadable = Cask::CaskUnreadableError.new("local-caffeine", "undefined method 'on_el_capitan'")

allow(described_class).to receive(:outdated_casks).and_return([local_caffeine])
allow(Cask::CaskLoader).to receive(:load_from_installed_caskfile).and_call_original
allow(Cask::CaskLoader).to receive(:load_from_installed_caskfile)
.with(local_caffeine.installed_caskfile)
.and_raise(unreadable)
allow(described_class).to receive(:recover_old_cask_from_current_definition).and_call_original
allow(described_class).to receive(:recover_old_cask_from_current_definition)
.with(local_caffeine)
.and_return(nil)

expect(described_class).not_to receive(:upgrade_cask)
expect do
described_class.upgrade_casks!(local_caffeine, dry_run: true, args:)
end.to raise_error(SystemExit)
.and output(/brew reinstall --cask --force local-caffeine/).to_stderr
end

it 'includes "auto_updates true" casks when the installed bundle version is older than the tap version' do
expect(described_class).not_to receive(:upgrade_cask)
expect(described_class).to receive(:show_upgrade_summary) do |cask_upgrades, dry_run:|
Expand Down Expand Up @@ -322,6 +432,40 @@ def write_info_plist(path, short_version:, bundle_version:)
end
end

context "when upgrading an auto-updating cask with unreadable metadata" do
# This test performs a real upgrade to verify end-to-end metadata recovery.
before do
Cask::Installer.new(Cask::CaskLoader.load(cask_path("outdated/auto-updates"))).install
end

it "upgrades successfully and refreshes metadata" do
expect(auto_updates).to be_installed
expect(auto_updates.installed_version).to eq "2.57"
expect(auto_updates_path).to be_a_directory

auto_updates.installed_caskfile.write <<~RUBY
cask "auto-updates" do
on_el_capitan do
end
end
RUBY

# Simulate app self-update outside Homebrew while metadata remains old.
write_info_plist(auto_updates_path, short_version: "2.60", bundle_version: "2060")

expect do
described_class.upgrade_casks!(args:)
end.to output(/Recovered unreadable installed metadata for auto-updates/).to_stderr

expect(auto_updates).to be_installed
expect(auto_updates.installed_version).to eq "2.61"

expect do
Cask::CaskLoader.load_from_installed_caskfile(auto_updates.installed_caskfile)
end.not_to raise_error
end
end

context "when there were multiple failures" do
# These tests perform actual upgrades and test error handling,
# so they need full real installations.
Expand Down
Loading