Compare commits

..

2 commits

Author SHA1 Message Date
maxsoch 3d6f39048a Merge branch 'fix/upgrade-failures' 2026-07-13 19:22:54 +02:00
maxsoch 861c356e80 upgrade formulae one at a time and surface failed upgrades
brew's batch upgrade --formula errors out on the first broken formula
(no bottle for this macOS, deprecated, …), blocking every other
pending formula — and the failure only showed in the logs, so the
menu silently fell back to "N outdated" as if nothing had run.
Formulae now upgrade individually like casks already did, failures
of any kind (including a failed reinstall fallback) are collected,
and the status line reports them once after the closing refetch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 19:22:54 +02:00

View file

@ -47,6 +47,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// last known outdated list, shown in the Upgrade All confirmation
var cachedOutdated: OutdatedPackages = .none
var lastNotifiedOutdated: Set<String> = []
/// packages whose last upgrade attempt failed; reset when an upgrade
/// run starts, displayed once by updateStatus after the run's refetch
var failedUpgrades: [String] = []
let pathMonitor = NWPathMonitor()
/// false until the monitor's first update, so the launch refresh waits
@ -354,12 +357,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusMenuItem.title = "Upgrading..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
runBrew("update") { _, _ in
// packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them
let formulaNames = formulae ? self.cachedOutdated.formulae.map(\.name) : []
let caskNames = casks ? self.cachedOutdated.casks.map(\.name) : []
let upgradeCasksThenRefresh = {
self.upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) {
self.cleanupAfterUpgradeIfEnabled {
// a manual upgrade just ran; don't chain an auto-upgrade
@ -367,14 +373,24 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
}
}
}
if formulae, !self.cachedOutdated.formulae.isEmpty {
self.runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh()
}
} else {
upgradeCasksThenRefresh()
/// Formulae also upgrade one at a time: a single broken formula (no
/// bottle for this macOS, deprecated, ) makes brew's batch upgrade
/// error out, taking every other pending formula down with it.
func upgradeFormulaeSequentially(_ names: [String], completion: @escaping () -> Void) {
guard let next = names.first else {
completion()
return
}
statusMenuItem.title = "Upgrading \(next)..."
runBrew("upgrade --formula \(next)", askpassMessage: "upgrade \(next)") { _, success in
if !success {
self.failedUpgrades.append(next)
}
self.upgradeFormulaeSequentially(Array(names.dropFirst()), completion: completion)
}
}
@ -398,12 +414,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// (An explicitly named cask upgrades even when self-updating, no
/// --greedy needed.)
func upgradeCask(_ name: String, completion: @escaping () -> Void) {
runBrew("upgrade --cask \(name)", askpassMessage: "upgrade \(name)") { output, _ in
runBrew("upgrade --cask \(name)", askpassMessage: "upgrade \(name)") { output, success in
guard output.contains("cannot be upgraded as-is") else {
if !success {
self.failedUpgrades.append(name)
}
completion()
return
}
self.runBrew("reinstall --cask --force \(name)", askpassMessage: "reinstall \(name)") { _, _ in
self.runBrew("reinstall --cask --force \(name)", askpassMessage: "reinstall \(name)") { _, reinstalled in
if !reinstalled {
self.failedUpgrades.append(name)
}
completion()
}
}
@ -507,23 +529,18 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func runAutoUpgrade(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
let formulaNames = formulae ? cachedOutdated.formulae.map(\.name) : []
let caskNames = casks ? cachedOutdated.casks.map(\.name) : []
let upgradeCasksThenRefresh = {
upgradeFormulaeSequentially(formulaNames) {
self.upgradeCasksSequentially(caskNames) {
self.cleanupAfterUpgradeIfEnabled {
self.fetchOutdated(allowAutoUpgrade: false)
}
}
}
if formulae {
runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh()
}
} else {
upgradeCasksThenRefresh()
}
}
func updateOutdatedMenu(with outdated: OutdatedPackages) {
@ -552,7 +569,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
func updateStatus(count: Int) {
if count == 0 {
// surface what the last upgrade run could not handle brew only
// reports these in its output, which lands in the logs. Consumed
// here so the next plain refresh shows the normal state again.
if !failedUpgrades.isEmpty {
statusMenuItem.title = "⚠️ Upgrade failed: \(failedUpgrades.joined(separator: ", ")) — see Logs"
failedUpgrades = []
} else if count == 0 {
statusMenuItem.title = "✅ All up to date"
} else {
statusMenuItem.title = "⚠️ \(count) outdated"
@ -564,6 +587,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusMenuItem.title = "Upgrading \(name)..."
setMenuBarIcon("brewbar-updating")
failedUpgrades = []
let finish = {
self.cleanupAfterUpgradeIfEnabled {
@ -576,7 +600,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if cachedOutdated.casks.contains(where: { $0.name == name }) {
upgradeCask(name, completion: finish)
} else {
runBrew("upgrade \(name)", askpassMessage: "upgrade \(name)") { _, _ in finish() }
runBrew("upgrade \(name)", askpassMessage: "upgrade \(name)") { _, success in
if !success {
self.failedUpgrades.append(name)
}
finish()
}
}
}