From 861c356e8078ab7ddd4515aaacc521f6cd5cfd63 Mon Sep 17 00:00:00 2001 From: maxsoch Date: Mon, 13 Jul 2026 19:22:54 +0200 Subject: [PATCH] upgrade formulae one at a time and surface failed upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- BrewBar/BrewBarApp.swift | 69 ++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/BrewBar/BrewBarApp.swift b/BrewBar/BrewBarApp.swift index afb85df..03d4727 100644 --- a/BrewBar/BrewBarApp.swift +++ b/BrewBar/BrewBarApp.swift @@ -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 = [] + /// 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() + } } }