fetch outdated as JSON; auto-upgrade only for matching package kinds

fetchOutdated now uses --json and the typed parser, so cachedOutdated
knows formulae from casks. The auto-upgrade trigger checks each kind
against its own toggle: an outdated cask no longer starts a pointless
"upgrade --formula" run (previously any outdated package triggered
whichever auto-upgrades were enabled). runAutoUpgrade takes explicit
formulae/casks flags instead of re-reading settings.

Bonus from the version info now available: outdated menu items and the
Upgrade All confirmation show "name installed → current" instead of
bare names. The legacy line parser and its tests are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
maxsoch 2026-07-06 22:27:37 +02:00
parent 7b3468126a
commit 813fc47461
3 changed files with 24 additions and 48 deletions

View file

@ -40,7 +40,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
/// last known outdated list, shown in the Upgrade All confirmation
var cachedOutdated: [String] = []
var cachedOutdated: OutdatedPackages = .none
var lastNotifiedOutdated: Set<String> = []
// MARK: - Menu
@ -240,7 +240,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var info = "Apps upgraded as casks (like a web browser) may be closed and replaced while running, without further warning. Save your work first."
if !cachedOutdated.isEmpty {
info += "\n\n" + cachedOutdated.joined(separator: "\n")
info += "\n\n" + cachedOutdated.all.map(\.label).joined(separator: "\n")
}
alert.informativeText = info
@ -259,25 +259,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setMenuBarIcon("brewbar-updating")
// --greedy also lists casks that self-update (brew skips them by default)
let command = Settings.includeGreedyCasks ? "outdated --greedy" : "outdated"
let command = Settings.includeGreedyCasks ? "outdated --greedy --json" : "outdated --json"
runBrew(command) { output in
let lines = BrewParser.parseOutdated(output)
let outdated = BrewParser.parseOutdatedJSON(output)
self.cachedOutdated = lines
self.cachedOutdated = outdated
if allowAutoUpgrade, !lines.isEmpty,
Settings.autoUpgradeFormulae || Settings.autoUpgradeCasks
{
self.runAutoUpgrade()
// only trigger for the kinds that are both enabled AND outdated
// e.g. an outdated cask must not start a pointless formulae upgrade
let upgradeFormulae = Settings.autoUpgradeFormulae && !outdated.formulae.isEmpty
let upgradeCasks = Settings.autoUpgradeCasks && !outdated.casks.isEmpty
if allowAutoUpgrade, upgradeFormulae || upgradeCasks {
self.runAutoUpgrade(formulae: upgradeFormulae, casks: upgradeCasks)
return
}
self.updateOutdatedMenu(with: lines)
self.updateStatus(count: lines.count)
self.updateOutdatedMenu(with: outdated)
self.updateStatus(count: outdated.count)
self.updateMenuBarCount()
self.notifyIfNeeded(outdated: lines)
if lines.isEmpty {
self.notifyIfNeeded(outdated: outdated.all.map(\.name))
if outdated.isEmpty {
self.setMenuBarIcon("brewbar-uptodate")
} else {
self.setMenuBarIcon("brewbar-outdated")
@ -303,11 +305,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
UNUserNotificationCenter.current().add(request)
}
func runAutoUpgrade() {
func runAutoUpgrade(formulae: Bool, casks: Bool) {
statusMenuItem.title = "Auto-upgrading..."
let upgradeCasksThenRefresh = {
if Settings.autoUpgradeCasks {
if casks {
let command = Settings.includeGreedyCasks ? "upgrade --cask --greedy" : "upgrade --cask"
self.runBrew(command) { _ in
self.fetchOutdated(allowAutoUpgrade: false)
@ -317,7 +319,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
if Settings.autoUpgradeFormulae {
if formulae {
runBrew("upgrade --formula") { _ in
upgradeCasksThenRefresh()
}
@ -326,14 +328,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
func updateOutdatedMenu(with lines: [String]) {
func updateOutdatedMenu(with outdated: OutdatedPackages) {
outdatedSubmenu.removeAllItems()
outdatedItem.isHidden = lines.isEmpty
upgradeAllItem.isHidden = lines.isEmpty
outdatedItem.isHidden = outdated.isEmpty
upgradeAllItem.isHidden = outdated.isEmpty
for formula in lines {
let item = NSMenuItem(title: formula, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
item.representedObject = formula
for package in outdated.all {
let item = NSMenuItem(title: package.label, action: #selector(upgradeSingle(_:)), keyEquivalent: "")
item.representedObject = package.name // the title has versions; brew needs the bare name
outdatedSubmenu.addItem(item)
}
}

View file

@ -32,12 +32,6 @@ struct OutdatedPackages: Codable, Equatable {
}
enum BrewParser {
static func parseOutdated(_ output: String) -> [String] {
output.split(separator: "\n")
.map { String($0) }
.filter { !$0.isEmpty }
}
/// Parses `brew outdated --json` output. Returns .none for anything
/// undecodable (brew error text, empty output) same effect as an
/// empty result, and the raw output is already in the log window.

View file

@ -2,26 +2,6 @@
import XCTest
final class BrewParserTests: XCTestCase {
// MARK: - parseOutdated
func testParseOutdated_multiplePackages() {
let output = "wget\nffmpeg\ngit\n"
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget", "ffmpeg", "git"])
}
func testParseOutdated_emptyOutput() {
XCTAssertEqual(BrewParser.parseOutdated(""), [])
}
func testParseOutdated_singlePackage() {
XCTAssertEqual(BrewParser.parseOutdated("wget\n"), ["wget"])
}
func testParseOutdated_trailingNewlinesIgnored() {
let output = "wget\n\n\n"
XCTAssertEqual(BrewParser.parseOutdated(output), ["wget"])
}
// MARK: - parseOutdatedJSON
func testParseOutdatedJSON_formulaeAndCasks() {