Update brew before the outdated check

brew info never auto-updates, so its API data can be up to 7 days old
and tap clones never refresh. Automatic loads and the reconcile after an
operation run brew update-if-needed, which applies HOMEBREW_NO_AUTO_UPDATE
and the update interval itself. A user's refresh runs brew update.

Fixes #235
This commit is contained in:
Ivan Dergachev
2026-09-28 00:16:26 +04:00
parent 9593bef36a
commit be50a45b1b
9 changed files with 69 additions and 300 deletions
@@ -1,47 +0,0 @@
//
// BrewConfigEnvironmentReader.swift
// BrewCLI
//
import BrewCore
import Foundation
/// Asks `brew config`, which prints `HOMEBREW_NO_INSTALL_FROM_API: set` only when it is set.
/// Probed once per app launch. Relaunch after changing this setting in `brew.env`.
public actor BrewConfigEnvironmentReader: HomebrewEnvironmentReading {
private static let noInstallFromAPIKey = "HOMEBREW_NO_INSTALL_FROM_API"
private let commandRunner: any BrewCommandRunning
private let locator: any BrewExecutableLocating
private var probed: Bool?
public init(commandRunner: any BrewCommandRunning, locator: any BrewExecutableLocating) {
self.commandRunner = commandRunner
self.locator = locator
}
public init(executionContext: BrewCommandExecutionContext) {
self.init(commandRunner: executionContext.commandRunner, locator: executionContext.locator)
}
public func isInstallFromAPIDisabled() async -> Bool {
if let probed {
return probed
}
let result = await probe()
probed = result
return result
}
/// Falls back to the API path; the caller's own brew invocation surfaces the real problem.
private func probe() async -> Bool {
guard let brew = try? locator.findBrewExecutable(),
let output = try? await commandRunner.run(executableURL: brew, arguments: ["config"]),
output.terminationStatus == 0
else {
return false
}
return BrewConfigParser.parse(output.standardOutput).entries
.contains { $0.key == Self.noInstallFromAPIKey }
}
}
@@ -1,12 +0,0 @@
//
// HomebrewEnvironmentReading.swift
// BrewCore
//
import Foundation
/// The environment Homebrew resolves from its configuration files, independent of the app's environment.
public protocol HomebrewEnvironmentReading: Sendable {
/// True when brew resolves packages from local tap clones rather than the JSON API.
func isInstallFromAPIDisabled() async -> Bool
}
@@ -36,29 +36,17 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
@ObservationIgnored private let commandRunner: BrewCommandRunning
@ObservationIgnored private let locator: any BrewExecutableLocating
@ObservationIgnored private let cache: InstalledInventoryCache
@ObservationIgnored private let environment: any HomebrewEnvironmentReading
@ObservationIgnored private let now: @Sendable () -> Date
/// Newest fetch in flight; fetches chain onto it so two refreshes cannot apply out of order.
@ObservationIgnored private var fetchTask: Task<Void, Never>?
/// Every mutating operation forces a fetch, so the tap refresh runs on an interval instead.
@ObservationIgnored private var lastTapUpdateAttempt: Date?
/// Homebrew's own `HOMEBREW_AUTO_UPDATE_SECS` default for the no-API path.
private static let tapRefreshInterval: TimeInterval = 300
public init(
commandRunner: BrewCommandRunning,
locator: any BrewExecutableLocating,
cache: InstalledInventoryCache,
environment: any HomebrewEnvironmentReading,
now: @escaping @Sendable () -> Date = Date.init,
) {
self.commandRunner = commandRunner
self.locator = locator
self.cache = cache
self.environment = environment
self.now = now
}
/// Takes the context rather than building its own runner, so the composition root points every
@@ -71,7 +59,6 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
commandRunner: executionContext.commandRunner,
locator: executionContext.locator,
cache: cache,
environment: BrewConfigEnvironmentReader(executionContext: executionContext),
)
}
@@ -97,7 +84,7 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
/// (Call `load()` — the no-arg convenience — via ``InstalledInventoryObserving``.)
public func load(forceRefresh: Bool) async {
guard !forceRefresh else {
await fetchAndStore()
await fetchAndStore(userRequested: true)
return
}
@@ -106,9 +93,9 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
apply(packages)
case let .stale(packages):
apply(packages)
await fetchAndStore()
await fetchAndStore(userRequested: false)
case .empty:
await fetchAndStore()
await fetchAndStore(userRequested: false)
}
}
@@ -116,11 +103,11 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
/// Waits for any fetch already in flight, then fetches. Joining it instead would answer a reconcile
/// with a snapshot taken before `brew` exited.
private func fetchAndStore() async {
private func fetchAndStore(userRequested: Bool) async {
let previous = fetchTask
let task = Task { @MainActor [weak self] in
await previous?.value
await self?.performFetch()
await self?.performFetch(userRequested: userRequested)
}
fetchTask = task
await task.value
@@ -129,9 +116,9 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
}
}
private func performFetch() async {
private func performFetch(userRequested: Bool) async {
do {
let packages = try await fetchInstalledPackages()
let packages = try await fetchInstalledPackages(userRequested: userRequested)
// Only a completed fetch clears this; repainting a cached snapshot answers nothing.
refreshFailure = nil
apply(packages)
@@ -156,9 +143,9 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
lookup = Dictionary(packages.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
}
private func fetchInstalledPackages() async throws -> [InstalledBrewPackage] {
private func fetchInstalledPackages(userRequested: Bool) async throws -> [InstalledBrewPackage] {
let brew = try locator.findBrewExecutable()
await updateTapsIfNeeded(executable: brew)
await updateBrew(executable: brew, userRequested: userRequested)
let output = try await runInstalledInfoJSON(executable: brew)
let payload = try decodeInfoJSON(from: output)
let packages = payload.installedPackages()
@@ -167,20 +154,14 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
return packages
}
/// `brew info` is not auto-updated by brew, and with the API off its data comes from tap clones —
/// so without this the outdated check answers from the user's last manual `brew update`, forever.
private func updateTapsIfNeeded(executable: URL) async {
guard await environment.isInstallFromAPIDisabled() else {
return
}
if let lastTapUpdateAttempt, now().timeIntervalSince(lastTapUpdateAttempt) < Self.tapRefreshInterval {
return
}
lastTapUpdateAttempt = now()
/// `brew info` never triggers brew's auto-update, so its API data can be 7 days old and tap clones
/// never refresh. A user's refresh runs `brew update`; automatic fetches run
/// `brew update-if-needed`, which updates only under the user's own auto-update settings.
private func updateBrew(executable: URL, userRequested: Bool) async {
do {
let output = try await commandRunner.run(
executableURL: executable,
arguments: ["update", "--auto-update", "--quiet"],
arguments: userRequested ? ["update", "--quiet"] : ["update-if-needed"],
)
guard output.terminationStatus == 0 else {
throw BrewCommandError.failed(exitCode: output.terminationStatus, stderr: output.standardError)
@@ -218,9 +199,10 @@ public final class BrewInstalledPackagesRepository: InstalledPackagesRepository
extension BrewInstalledPackagesRepository: BrewOperationReconciling {
/// Refetches the inventory before the command center publishes the terminal phase. Survives
/// cancellation of the submitting task, since ``fetchAndStore()`` works in an unstructured task.
/// cancellation of the submitting task, since ``fetchAndStore(userRequested:)`` works in an
/// unstructured task.
public func reconcile() async {
await load(forceRefresh: true)
await fetchAndStore(userRequested: false)
}
}
@@ -17,6 +17,7 @@ public protocol InstalledInventoryObserving: Observable, Sendable {
/// revalidation, so without this a surface cannot tell "nothing outdated" from "never found out".
var refreshFailure: (any Error)? { get }
/// `forceRefresh` is the user's explicit refresh, so it also runs a full `brew update`.
func load(forceRefresh: Bool) async
}
@@ -15,19 +15,6 @@ public struct MissingBrewExecutableLocator: BrewExecutableLocating {
}
}
/// Fixed ``HomebrewEnvironmentReading`` answer, so tests need no `brew config`.
public struct StubHomebrewEnvironment: HomebrewEnvironmentReading {
private let installFromAPIDisabled: Bool
public init(installFromAPIDisabled: Bool) {
self.installFromAPIDisabled = installFromAPIDisabled
}
public func isInstallFromAPIDisabled() async -> Bool {
installFromAPIDisabled
}
}
/// Per-invocation result for ``MockBrewCommandRunner``.
public enum MockBrewCommandRunnerBehavior: Sendable {
case output(CommandOutput)
@@ -77,9 +64,12 @@ public actor QueuedBrewInfoRunner: BrewCommandRunning {
public func run(
executableURL _: URL,
arguments _: [String],
arguments: [String],
options _: BrewRunOptions,
) async throws -> CommandOutput {
guard arguments.first == "info" else {
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
}
let output = outputs[min(index, outputs.count - 1)]
if index < outputs.count - 1 {
index += 1
@@ -20,8 +20,6 @@ public enum InstalledPackagesTestSupport {
commandRunner: BrewCommandRunning,
locator: (any BrewExecutableLocating)? = nil,
cache: InstalledInventoryCache? = nil,
environment: any HomebrewEnvironmentReading = StubHomebrewEnvironment(installFromAPIDisabled: false),
now: @escaping @Sendable () -> Date = Date.init,
) -> BrewInstalledPackagesRepository {
let resolvedCache = cache ?? InstalledInventoryCache()
let resolvedLocator = locator ?? BrewExecutableLocator(overrideURL: fakeBrewExecutableURL)
@@ -29,8 +27,6 @@ public enum InstalledPackagesTestSupport {
commandRunner: commandRunner,
locator: resolvedLocator,
cache: resolvedCache,
environment: environment,
now: now,
)
}
@@ -1,119 +0,0 @@
//
// BrewConfigEnvironmentReaderTests.swift
// BrewTests
//
import BrewCLI
import BrewCore
import BrewServicesTestSupport
import Foundation
import Testing
struct BrewConfigEnvironmentReaderTests {
private static let brewURL = URL(fileURLWithPath: "/fake/brew")
/// `brew config` prints the row only when the variable is set, so presence is the signal.
private static func configOutput(includingNoInstallFromAPI: Bool) -> String {
var lines = [
"HOMEBREW_VERSION: 6.0.20",
"HOMEBREW_PREFIX: /opt/homebrew",
]
if includingNoInstallFromAPI {
lines.append("HOMEBREW_NO_INSTALL_FROM_API: set")
}
lines.append("macOS: 26.5-arm64")
return lines.joined(separator: "\n")
}
private static func reader(
behaviors: [[String]: MockBrewCommandRunnerBehavior],
) -> BrewConfigEnvironmentReader {
BrewConfigEnvironmentReader(
commandRunner: MockBrewCommandRunner(behaviors: behaviors),
locator: BrewExecutableLocator(overrideURL: brewURL),
)
}
@Test func `reports the API disabled when brew config lists the variable`() async {
let reader = Self.reader(behaviors: [
["config"]: .output(CommandOutput(
standardOutput: Self.configOutput(includingNoInstallFromAPI: true),
standardError: "",
terminationStatus: 0,
)),
])
#expect(await reader.isInstallFromAPIDisabled())
}
@Test func `reports the API in use when brew config omits the variable`() async {
let reader = Self.reader(behaviors: [
["config"]: .output(CommandOutput(
standardOutput: Self.configOutput(includingNoInstallFromAPI: false),
standardError: "",
terminationStatus: 0,
)),
])
#expect(await reader.isInstallFromAPIDisabled() == false)
}
@Test func `falls back to the API path when brew config cannot run`() async {
let reader = Self.reader(behaviors: [
["config"]: .throw(BrewCommandError.launchFailed(underlying: "could not spawn brew")),
])
#expect(await reader.isInstallFromAPIDisabled() == false)
}
@Test func `falls back to the API path when brew config exits non zero`() async {
let reader = Self.reader(behaviors: [
["config"]: .output(CommandOutput(
standardOutput: Self.configOutput(includingNoInstallFromAPI: true),
standardError: "boom",
terminationStatus: 1,
)),
])
// Non-zero output is not trustworthy, even when it happens to contain the row.
#expect(await reader.isInstallFromAPIDisabled() == false)
}
@Test func `falls back to the API path when brew cannot be located`() async {
let reader = BrewConfigEnvironmentReader(
commandRunner: MockBrewCommandRunner(behaviors: [:]),
locator: MissingBrewExecutableLocator(),
)
#expect(await reader.isInstallFromAPIDisabled() == false)
}
@Test func `the answer is probed once and reused`() async {
let runner = CountingConfigRunner(
standardOutput: Self.configOutput(includingNoInstallFromAPI: true),
)
let reader = BrewConfigEnvironmentReader(
commandRunner: runner,
locator: BrewExecutableLocator(overrideURL: Self.brewURL),
)
#expect(await reader.isInstallFromAPIDisabled())
#expect(await reader.isInstallFromAPIDisabled())
#expect(await runner.callCount == 1)
}
}
private actor CountingConfigRunner: BrewCommandRunning {
private let standardOutput: String
private(set) var callCount = 0
init(standardOutput: String) {
self.standardOutput = standardOutput
}
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
callCount += 1
return CommandOutput(standardOutput: standardOutput, standardError: "", terminationStatus: 0)
}
}
@@ -469,7 +469,10 @@ private actor ConcurrencyTrackingInfoRunner: BrewCommandRunning {
private(set) var maxConcurrent = 0
private var current = 0
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
guard arguments.first == "info" else {
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
}
callCount += 1
current += 1
maxConcurrent = max(maxConcurrent, current)
@@ -492,7 +495,10 @@ private actor FlakyInfoRunner: BrewCommandRunning {
self.firstError = firstError
}
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
guard arguments.first == "info" else {
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
}
guard didThrow else {
didThrow = true
throw firstError
@@ -509,7 +515,10 @@ private actor FlakyInfoRunner: BrewCommandRunning {
private actor CountingInfoRunner: BrewCommandRunning {
private(set) var callCount = 0
func run(executableURL _: URL, arguments _: [String], options _: BrewRunOptions) async throws -> CommandOutput {
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
guard arguments.first == "info" else {
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
}
callCount += 1
return CommandOutput(
standardOutput: #"{ "formulae": [], "casks": [] }"#,
@@ -11,59 +11,62 @@ import BrewServicesTestSupport
import Foundation
import Testing
/// With the API off, package data comes from tap clones that only `brew update` refreshes.
/// `brew info` never auto-updates, so brew is updated first: fully when the user asks, otherwise
/// through `brew update-if-needed` so brew's own auto-update settings apply.
struct BrewInstalledPackagesTapRefreshTests {
private static let emptyInfoJSON = #"{ "formulae": [], "casks": [] }"#
private static let update = ["update", "--quiet"]
private static let updateIfNeeded = ["update-if-needed"]
private static let info = ["info", "--installed", "--json=v2"]
@Test @MainActor func `taps are updated before the outdated check when the API is disabled`() async {
@Test @MainActor func `a user refresh runs a full brew update every time`() async {
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
)
await repo.load(forceRefresh: true)
await repo.load(forceRefresh: true)
#expect(await runner.invocations == [
["update", "--auto-update", "--quiet"],
["info", "--installed", "--json=v2"],
])
#expect(await runner.invocations == [Self.update, Self.info, Self.update, Self.info])
#expect(repo.state.isLoaded)
}
@Test @MainActor func `taps are left alone when brew reads from the API`() async {
@Test @MainActor func `a first load lets brew decide whether to update`() async {
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
environment: StubHomebrewEnvironment(installFromAPIDisabled: false),
)
await repo.load(forceRefresh: true)
await repo.load()
// brew refreshes the API files on its own TTL, so an update here is pure cost.
#expect(await runner.invocations == [["info", "--installed", "--json=v2"]])
#expect(await runner.invocations == [Self.updateIfNeeded, Self.info])
}
@Test @MainActor func `the tap update runs on an interval rather than before every fetch`() async {
let clock = MutableClock(now: Date(timeIntervalSince1970: 0))
@Test @MainActor func `the reconcile after an operation lets brew decide whether to update`() async {
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
now: clock.dateProvider,
)
await repo.load(forceRefresh: true)
clock.now = Date(timeIntervalSince1970: 120)
await repo.load(forceRefresh: true)
await repo.reconcile()
#expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 1)
#expect(await runner.invocations == [Self.updateIfNeeded, Self.info])
#expect(repo.state.isLoaded)
}
// Past Homebrew's 5-minute interval for this mode.
clock.now = Date(timeIntervalSince1970: 400)
await repo.load(forceRefresh: true)
@Test @MainActor func `every automatic fetch leaves the update interval to brew`() async {
let cache = InstalledInventoryCache()
let runner = RecordingCommandRunner(infoJSON: Self.emptyInfoJSON)
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
cache: cache,
)
#expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 2)
await Self.automaticFetch(repo, cache: cache)
await Self.automaticFetch(repo, cache: cache)
#expect(await runner.count(of: Self.updateIfNeeded) == 2)
}
@Test @MainActor func `a failed tap update still lets the outdated check answer`() async {
@@ -74,7 +77,6 @@ struct BrewInstalledPackagesTapRefreshTests {
)
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
)
await repo.load(forceRefresh: true)
@@ -84,26 +86,6 @@ struct BrewInstalledPackagesTapRefreshTests {
#expect(await runner.invocations.contains(["info", "--installed", "--json=v2"]))
}
@Test @MainActor func `a persistently failing tap update does not stall every fetch`() async {
let clock = MutableClock(now: Date(timeIntervalSince1970: 0))
let runner = RecordingCommandRunner(
infoJSON: Self.emptyInfoJSON,
updateBehavior: .throwing,
)
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
now: clock.dateProvider,
)
await repo.load(forceRefresh: true)
clock.now = Date(timeIntervalSince1970: 60)
await repo.load(forceRefresh: true)
// The attempt is timestamped even when it fails, so the interval still applies.
#expect(await runner.count(of: ["update", "--auto-update", "--quiet"]) == 1)
}
@Test @MainActor func `a cache-first load that skips the fetch also skips the tap update`() async {
let cache = InstalledInventoryCache()
await cache.replace(
@@ -113,39 +95,28 @@ struct BrewInstalledPackagesTapRefreshTests {
let repo = InstalledPackagesTestSupport.repository(
commandRunner: runner,
cache: cache,
environment: StubHomebrewEnvironment(installFromAPIDisabled: true),
)
await repo.load()
#expect(await runner.invocations.isEmpty)
}
/// A stale cache is what launch and tab loads find, so `load()` refetches without the user asking.
@MainActor
private static func automaticFetch(_ repo: BrewInstalledPackagesRepository, cache: InstalledInventoryCache) async {
await cache.replace(InstalledInventorySnapshot(fetchedAt: .distantPast, packages: []))
await repo.load()
}
}
// MARK: - Doubles
/// Mutable time source, so interval behaviour is asserted without waiting.
@MainActor
private final class MutableClock {
var now: Date
init(now: Date) {
self.now = now
}
nonisolated var dateProvider: @Sendable () -> Date {
// The repository is @MainActor; the synchronous @Sendable closure type can't say so.
// swiftlint:disable:next assume_isolated
{ MainActor.assumeIsolated { self.now } }
}
}
/// Records the `brew` argument lists it was asked to run.
private actor RecordingCommandRunner: BrewCommandRunning {
enum UpdateBehavior {
case success
case failure(exitCode: Int32, stderr: String)
case throwing
}
private let infoJSON: String
@@ -163,7 +134,7 @@ private actor RecordingCommandRunner: BrewCommandRunning {
func run(executableURL _: URL, arguments: [String], options _: BrewRunOptions) async throws -> CommandOutput {
invocations.append(arguments)
guard arguments.first == "update" else {
guard arguments.first == "update-if-needed" || arguments.first == "update" else {
return CommandOutput(standardOutput: infoJSON, standardError: "", terminationStatus: 0)
}
switch updateBehavior {
@@ -171,8 +142,6 @@ private actor RecordingCommandRunner: BrewCommandRunning {
return CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
case let .failure(exitCode, stderr):
return CommandOutput(standardOutput: "", standardError: stderr, terminationStatus: exitCode)
case .throwing:
throw BrewCommandError.launchFailed(underlying: "could not spawn brew")
}
}
}