surface brew failures instead of reporting a false all-up-to-date

runBrew now reports the process exit status to its completion handler.
refreshAll and fetchOutdated stop on failure and show "Check failed —
see Logs": NWPathMonitor cannot see a network that is up but broken
(captive portal, dead DNS, git host down), while the exit code of the
brew command itself catches every failure mode. Upgrade commands keep
ignoring the flag on purpose — a failed upgrade leaves the package in
the outdated list at the next fetch, so the final state stays correct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
maxsoch 2026-07-09 19:28:09 +02:00
parent c6372ece05
commit 64b9bae564

View file

@ -220,7 +220,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// askpassMessage customizes the sudo dialog ("...password to <message>.");
/// defaults to naming the brew command.
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (String) -> Void = { _ in }) {
func runBrew(_ command: String, askpassMessage: String? = nil, completion: @escaping (_ output: String, _ success: Bool) -> Void = { _, _ in }) {
Self.brewQueue.async {
let brew = self.resolveBrewPath()
let cleaned = command.replacingOccurrences(of: "brew ", with: "")
@ -246,7 +246,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
try process.run()
} catch {
self.log("ERROR: \(error)\n")
DispatchQueue.main.async { completion("") }
DispatchQueue.main.async { completion("", false) }
return
}
@ -256,8 +256,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let output = String(data: data, encoding: .utf8) ?? ""
self.log(output)
let success = process.terminationStatus == 0
if !success {
self.log("✗ exited with status \(process.terminationStatus)\n")
}
// completion always on main: callers update AppKit UI, which is main-thread only
DispatchQueue.main.async { completion(output) }
DispatchQueue.main.async { completion(output, success) }
}
}
@ -274,7 +278,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusMenuItem.title = "Upgrading..."
runBrew("update") { _ in
runBrew("update") { _, _ in
// packages that turn outdated only after this brew update are not
// in cachedOutdated yet; the final refetch will surface them
let caskNames = self.cachedOutdated.casks.map(\.name)
@ -288,7 +292,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if self.cachedOutdated.formulae.isEmpty {
upgradeCasksThenRefresh()
} else {
self.runBrew("upgrade --formula") { _ in
self.runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh()
}
}
@ -305,7 +309,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusMenuItem.title = "Upgrading \(next)..."
// an explicitly named cask upgrades even when self-updating, no --greedy needed
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _ in
runBrew("upgrade --cask \(next)", askpassMessage: "upgrade \(next)") { _, _ in
self.upgradeCasksSequentially(Array(names.dropFirst()), completion: completion)
}
}
@ -343,7 +347,15 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// --greedy also lists casks that self-update (brew skips them by default)
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
runBrew(command) { output in
runBrew(command) { output, success in
// parsing a failure's output would yield .none and masquerade as
// "all up to date"; keep the cached state and its icon instead
guard success else {
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
self.setMenuBarIcon(self.cachedOutdated.isEmpty ? "brewbar-uptodate" : "brewbar-outdated")
return
}
let outdated = BrewParser.parseOutdatedJSON(output)
self.cachedOutdated = outdated
@ -402,7 +414,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
if formulae {
runBrew("upgrade --formula") { _ in
runBrew("upgrade --formula") { _, _ in
upgradeCasksThenRefresh()
}
} else {
@ -441,7 +453,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func upgradeSingle(_ sender: NSMenuItem) {
guard let formula = sender.representedObject as? String else { return }
runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _ in
runBrew("upgrade \(formula)", askpassMessage: "upgrade \(formula)") { _, _ in
self.refreshAll()
}
}
@ -472,14 +484,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
statusMenuItem.title = "Updating..."
runBrew("update") { _ in
// NWPathMonitor can't see a network that is up but broken (captive
// portal, dead DNS, git host down); brew update's exit code can.
// Proceeding anyway would read stale local data and report a false OK.
runBrew("update") { _, success in
guard success else {
self.statusMenuItem.title = "⚠️ Check failed — see Logs"
return
}
self.fetchOutdated() // ONLY place calling outdated
}
}
/// Brew version (correct)
func refreshBrewVersion() {
runBrew("--version") { output in
runBrew("--version") { output, _ in
let firstLine = BrewParser.parseVersion(output)
self.versionItem.title = "🏷 \(firstLine)"
}