mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-02 00:24:33 +08:00
- Read user configuration from `brew.env`, independent of shell setup. - Filter system startup banners while preserving startup diagnostics. - Drain terminal output before closing its descriptors to prevent loss. - Use the same restricted environment for app self-upgrades. - Document the policy and configure live CI through `brew.env`.
108 KiB
108 KiB
AI Memory — BrewUI
Long-term knowledge about this project. Append new entries; do not delete history. Format:
## YYYY-MM-DD — Topic
2026-03-03 — Project Initialised
- Project: BrewUI — Homebrew's official macOS GUI
- Stage: Early / scaffolding. AI config layer created. Prototype context migrated from separate repo.
- Primary agents in use: Claude, Cursor. Designed to be agent-agnostic.
- AI config files created:
AGENTS.md,CLAUDE.md,.cursorrules,CONVENTIONS.md,ARCHITECTURE.md,.ai/directory. - Key rule: All agents must read
AGENTS.mdat the start of each session and update.ai/progress.mdat the end. - Next step: Migrate project-specific context from prototype repo. Update placeholder sections in
CONVENTIONS.md,ARCHITECTURE.md, and this file.
2026-03-03 — Tech Stack & Architecture Confirmed
- Language: Swift 6.0, strict concurrency mode enabled
- UI: SwiftUI (pure — AppKit only when SwiftUI cannot meet the requirement)
- State:
@Observable(Swift 5.9+);async/awaitthroughout - Package manager: Swift Package Manager
- macOS targets: Tahoe 26, Sequoia 15, Sonoma 14 — minimum Tahoe 26
- Data sources:
brewCLI subprocess + Homebrew JSON API (formulae.brew.sh) - Architecture pattern decided: Views → ViewModels → Repositories/Interactors → Services → external (brew CLI / JSON API)
- Repository naming: protocol =
*Repository, real impl =Brew*Repository, mock =Mock*Repository - Interactor naming: protocol =
*Interacting, impl =*Interactor, mock =Mock*Interactor - Formatter:
swift-format(official Swift project tool) - Test framework: Swift Testing (preferred) or XCTest
- No
try!or force-unwrap anywhere — not in production, not in tests. Use#require/XCTUnwrapin tests. - Accessibility identifiers: single source of truth in
Utilities/AccessibilityIdentifiers.swift, shared between app and UI test targets
2026-03-03 — Additional Decisions
- Sandboxing: App is unsandboxed. No entitlements needed for subprocess execution.
- Homebrew detection: Check
/opt/homebrew/bin/brew(Apple Silicon) then/usr/local/bin/brew(Intel). Degrade gracefully if neither found. - JSON API schema drift: Prefer tolerant handling for unknown fields; required-field policy is endpoint-specific (see 2026-05-18 strict catalogue decoding entry).
2026-03-11 — Developer Hook Workflow
- Pre-commit enforcement: Repository-managed pre-commit hook runs staged Swift files through Mint (
mint run swiftformat, thenmint run swiftlintwith--fix, then strictmint run swiftlint). See 2026-04-12 — Mint for SwiftFormat and SwiftLint. After format/lint it onlygit adds fully staged files whose worktree blob changed; partially staged files are blocked only when format/lint actually changed the file on disk (not merely because index ≠ worktree). - Bootstrap integration:
./scripts/bootstrapinstalls git hooks automatically viascripts/install-git-hooksto minimize manual setup for contributors.
2026-04-12 — Mint for SwiftFormat and SwiftLint
- Version pins: SwiftFormat and SwiftLint versions live in root
Mintfile(nicklockwood/SwiftFormat,realm/SwiftLint). - Install path: Homebrew (
Brewfile) installs Mint only;./scripts/bootstraprunsmint bootstrapso pinned tools are built once and cached under Mint. - Enforcement: Pre-commit and
swift_qualityCI invoke tools viamint run swiftformat/mint run swiftlintfrom the repo root (Mintfile discovery), not global Homebrew formulae for those binaries.
2026-03-11 — Lint/Format Baseline Config
- Pinned Swift version for tooling: Added root
.swift-versionwith6.2(aligned to latest installed stable Swift 6.2.4) for deterministic SwiftFormat behavior. - Project formatter config: Added root
.swiftformatwith explicit Swift version and baseline whitespace/line-ending settings. - Project linter config: Added root
.swiftlint.ymlwith scoped includes/excludes and practical early-stage defaults forline_lengthandidentifier_name.
2026-03-11 — PR CI Baseline
- PR checks policy: Required PR checks are lightweight and path-scoped for fast feedback.
- Workflow split: CI is separated into focused workflows (
swift_quality,pr_build_test,actionlint,ui_smoke) instead of a monolithic pipeline. - Optional heavy check: UI smoke testing is explicitly opt-in via manual
workflow_dispatch(ui_smoke.yml) and is not required by default.
2026-03-14 — Project Naming Renamed To Brew
- Xcode project/scheme/targets were renamed from
BrewUItoBrewfor clarity. - Test targets now map as:
BrewTests= unit testsBrewUITests= UI tests
- Repository root folder remains
BrewUI(part of a larger parent project layout). - App bundle identifier and installer package identifier changed to
sh.brew.app(wassh.brew.BrewUI). Test bundle identifiers track renamed targets (sh.brew.BrewTestsandsh.brew.BrewUITests).
2026-03-15 — Actionlint Policy-Compliant Pattern
- GitHub workflow linting should avoid
uses: docker://...because org allowlist policy can reject it. - Preferred pattern for this repo is Homebrew-influenced and allowlist-friendly:
- use
Homebrew/actions/setup-homebrew@1f8e202ffddf94def7f42f6fa3a482e821489f9c # 2026.07.10.1 - use
Homebrew/actions/cache-homebrew-prefix@1f8e202ffddf94def7f42f6fa3a482e821489f9c # 2026.07.10.1to installactionlint/shellcheck - run
actionlintvia arun:step
- use
- Actionlint remains path-scoped to workflow changes for low CI overhead.
2026-03-20 — Decision logging (no ADRs)
- No
docs/adr/: Architecture Decision Records are not used in this repo. - Where “why” lives: Durable decisions, constraints, and rationale go in
.ai/memory.md(dated entries, append-only history). ARCHITECTURE.md: Describes structure and how pieces fit; add extra explanation only when something is unusual or easy to misread.
2026-03-21 — Documentation ownership (ARCHITECTURE vs CONVENTIONS)
ARCHITECTURE.mdis the single source of truth for system shape, layers, data flow, file/folder layout, tech stack baseline, and whereAccessibilityIdentifiers.swiftlives.CONVENTIONS.mdowns naming rules, implementation patterns, and contributor-facing how-to; it should reference architecture instead of repeating topology or the stack table.
2026-03-21 — PR template stays minimal
- Do not add standing checklist items to
.github/PULL_REQUEST_TEMPLATE.mdfor doc deduplication (or similar); the template should stay short and not grow indefinitely. - Doc duplication: rely on
Document scope/ ownership matrix inCONVENTIONS.md, cross-links inARCHITECTURE.md, and review judgment — not extra PR checkboxes.
2026-03-22 — Lightweight ARCHITECTURE / CONVENTIONS
ARCHITECTURE.mdandCONVENTIONS.mdare intentionally minimal for the early scaffolding phase; grow them as real code and patterns appear.- Product / platform constraints live under Constraints & decisions in
ARCHITECTURE.mdonly (not duplicated inCONVENTIONS.md). CONVENTIONS.mdholds BrewUI-specific naming deltas, tooling pointers, and short implementation notes; generic Swift/SwiftUI guidance defers to Apple docs + SwiftLint/SwiftFormat.- Doc deduplication: use the opening blockquotes and cross-links between the two files; the old standalone “ownership matrix” in
CONVENTIONS.mdwas removed in favour of that lighter approach.
2026-03-27 — Relative doc links rule
- Added
.cursor/rules/doc-relative-links.mdcto require relative Markdown links for intra-repo doc references (avoid absolute GitHub blob URLs in docs).
2026-04-03 — Design system enforcement
- Where it lives:
Brew/Theme/(BrewColors,BrewSpacing/BrewLayout/BrewRadius,BrewFonts) is the single source for UI semantics;CONVENTIONS.mdhas a Design system section; agents use.cursor/rules/design-system.mdc(globsBrew/**/*.swift) alongsideswift-implementation.mdc. - Rule: Extend Theme when adding new semantics — avoid raw hex or magic numbers in feature views (already stated in
BrewColors.swiftcomments).
2026-04-03 — InstalledViewModel dummy data (Swift 6)
InstalledViewModelDummyDatalives in its own file with static sample arrays.InstalledViewModel.inittakes optional row arrays and applies?? InstalledViewModelDummyData.*in the initializer body — do not use= InstalledViewModelDummyData.formulaeas default parameter values, or Swift 6 reports main-actor / default-argument isolation issues.
2026-04-04 — Installed packages fetch layer
- Flow:
InstalledViewModel→InstalledPackagesRepository→BrewCommandRunning+BrewExecutableLocator. Parsing is pureInstalledPackagesParseronbrew list --versions --formula|caskstdout (ARCHITECTURE.md— tolerant CLI handling). - Tests: Mock
BrewCommandRunningwith a[[String]: CommandOutput]map; never run realbrewin unit tests (CONVENTIONS.mdTesting).BrewExecutableLocator(overrideURL:)exists for tests only.
2026-04-04 — App Sandbox disabled on Brew target
- Drift: Xcode had
ENABLE_APP_SANDBOX = YESwhileARCHITECTURE.mdspecifies an unsandboxed app (2026-03-03 — Additional Decisions). Sandboxing prevented seeing/executing/opt/homebrew/bin/brewand writing session logs under the repo.cursor/path. - Fix:
ENABLE_APP_SANDBOX = NOfor the Brew app target (Debug and Release). Revisit sandbox + entitlements only if distribution constraints require it.
2026-04-04 — Installed packages slice tests
- Pattern:
InstalledViewModelTestsandBrewInstalledPackagesRepositoryTestsuse the realBrewInstalledPackagesRepositorywith boundary fakes only:MockBrewCommandRunner+BrewExecutableLocator(overrideURL:)orMissingBrewExecutableLocator(BrewExecutableLocating). Shared helpers:BrewTests/TestSupport/InstalledPackagesRepositoryTestSupport.swift. Documented inCONVENTIONS.mdTesting.
2026-04-04 — Main window / sidebar VM (deferred)
- When
SidebarItemgains a second case (e.g. Discover), introduce aMainWindowViewModel(orAppShellViewModel) to own selection and any tab rules; keepContentView’sswitchonly for constructing child views. Presentation for sidebar rows can move there for unit tests.
2026-04-25 — Main window: three-column NavigationSplitView
- Layout: The main window uses the three-column
NavigationSplitViewinitializer: sidebar (ShellSidebarView), content (InstalledShellView— list + chrome only), detail (InstalledPackageDetailView/InstalledPackageDetailPlaceholder). The previousHSplitViewinside the detail region is removed; column widths useBrewLayouttokens includinginstalledListColumn*andinstalledDetailColumn*.minWindowWidthis the sum of sidebar, list, and detail minimum column widths. Installed data loading runs fromContentViewvia.task(id: selectedSidebarItem)when the Installed tab is selected.
2026-04-26 — App shell decomposition (MVVM-C lightweight)
- Added
MainWindowView+MainWindowViewModelso shell layout/navigation selection/load policy are separated from feature views. InstalledColumnsnow owns Installed feature column composition (contentColumn,detailColumn) and related width modifiers.BrewAppnow presentsMainWindowViewdirectly;ContentViewremains a thin compatibility wrapper for previews/incremental migration.
2026-04-26 — Loadable view state convention
- For async/failable view-model data that drives UI rendering, prefer a single enum state (for example
.loading,.loaded(Data),.error(String)) over separateisLoading/data/errorproperties. - This pattern is now used by
InstalledDetailsViewModelviaInstalledDetailsLoadState, and documented inCONVENTIONS.mdunder Implementation notes.
2026-04-27 — Installed list source migrated to brew info JSON
BrewInstalledPackagesRepositorynow hydrates installed list data from a singlebrew info --installed --json=v2call instead ofbrew list --versionstext output.- Existing Installed list UI contract remains stable because repository output is still
InstalledPackagesSnapshot, mapped/sorted beforeInstalledViewModelrow mapping. - Tests now validate mixed formula/cask JSON payloads, optional/missing fields, command failure behavior, and invalid JSON decode failures for installed list loading.
2026-04-27 — Brew command pipe-drain fix
BrewCommandServicenow starts concurrent stdout/stderr readers immediately after process launch and only then waits for process termination, avoiding wait-before-read deadlocks on large command output.- Added
BrewCommandServiceTestsincluding a large output regression case (250k chars on each stream) to protect command execution paths used for installed list and details loading.
2026-05-11 — Noop command center + main window VM wiring
NoopBrewCommandCenter: keep the existingpreview()andforTesting()helpers; avoid renaming preview/test helpers during visibility-only sweeps.BrewCommandExecutionContext: keepnoopForTestingAndPreviews()for existing noop subprocess wiring.- Main window: keep sidebar selection as local
@StateinMainWindowView;InstalledColumnsRootremains the dependency-composition boundary perCONVENTIONS.md. - Encapsulation:
upgradeOperationPhaseisprivateon list/detail VMs where onlyisUpgrading/showsUpgradeBusyare user-facing.
2026-05-03 — BrewCommandCenter + operation IDs
- Protocol
BrewCommandCenter:actorprotocol —submit(id:command:),phase(for:),phaseByID()(full snapshot map),isActive(id:)(allasyncfrom callers). BrewMutatingCommand:Sendablecommand pattern —var operationKind,func run(in: BrewCommandExecutionContext) async throws(no caller closures; kind is not duplicated atsubmit).BrewCommandExecutionContext:commandRunner(BrewCommandRunning) +brewExecutableURL()vialocator(BrewExecutableLocating).SerialBrewCommandCenter:actor;SerialBrewWorkQueueinner actor ensures one mutating command at a time acrossawait; duplicateBrewOperationIDcoalesces via sharedTaskhandle.OperationFailure:Sendableenum(.brewCommand,.brewLaunchFailed,.brewExecutableNotFound,.generic) forBrewOperationPhase.failed(reason:);init(catching:)mapsBrewCommandError,BrewLookupError, and other errors to cases;userFacingMessageis a derived line for UI.- Transport types (
BrewOperationModels.swift):BrewOperationKind,BrewOperationID,BrewOperationPhase. - Domain package discriminator:
HomebrewPackageKind;InstalledPackageKindtypealias;BrewOperationID.init(kind:name:)inBrewOperationID+Homebrew.swift. - Composition:
BrewAppholdsSerialBrewCommandCenter(executionContext: .live())and applies.environment(\.brewCommandCenter, center)toMainWindowView.MainWindowViewkeeps sidebar selection in local@Stateand embedsInstalledColumnsRoot(). The root view owns dependency composition for the Installed surface by reading@Environment(\.brewCommandCenter)and constructingInstalledColumns(repository:brewCommandCenter:). Previews useNoopBrewCommandCenter.preview()and.environment(\.brewCommandCenter, …); unit tests constructSerialBrewCommandCenter,NoopBrewCommandCenter.forTesting(), orRecordingSerialBrewCommandCenteras needed.
2026-05-04 — Installed package upgrades via command center
- Upgrade path:
InstalledDetailsViewModelcallsawait brewCommandCenter.submit(id:command:)withBrewOperationID(row: selectedRow)andPackageUpgradeCommand(row: selectedRow)(samekind:nameasInstalledPackageRow/id;PackageUpgradeCommandimplementsBrewMutatingCommandwithBrewCommandExecutionContext; mirrorsbrew upgrade/brew upgrade --caskargv split).BrewOperationID.init(row:)delegates toinit(kind:name:). InstalledViewModeltakesbrewCommandCenter: any BrewCommandCenterininit(repository:brewCommandCenter:);BrewApppasses the sameSerialBrewCommandCenterinstance as for.environment(\.brewCommandCenter, …). RemovedPackageUpgradeRunning/BrewPackageUpgradeService.
2026-05-04 — Detail-upgrade task lifetime
InstalledDetailsViewModel.upgradeSelectedPackage()now starts and owns an unstructured task (upgradeTask) so upgrade execution viabrewCommandCenter.submitis not canceled by a view-scoped caller task when navigating away from detail UI.InstalledPackageDetailViewinvokesupgradeSelectedPackage()directly (no view-levelTask { ... }wrapper), keeping task-lifetime policy in the view model.
2026-05-05 — Per-ID phaseChanges + list row VM
BrewCommandCenter: addedphaseChanges(for: BrewOperationID) async -> AsyncStream<BrewOperationPhase>— multicast per id inSerialBrewCommandCenterwithcontinuation.onTerminationcleanup;NoopBrewCommandCenteryieldsBrewOperationPhase.idleonce;RecordingSerialBrewCommandCenterforwards toinner.- Use
AsyncStream<Element>(bufferingPolicy: .unbounded) { … }to pick the continuation-based initializer (plainAsyncStream { … }can resolve tounfoldingunder default actor isolation). - Installed list:
InstalledListRowViewModel(observeRowUpdates) +InstalledListRowRootwith.task(id: row.id); removed parentupgradeBusyRowIDspolling loop fromInstalledViewModel/InstalledPackagesView.
2026-05-06 — Installed repository narrow single-package read
InstalledPackagesRepository: addedloadInstalledPackage(kind:named:) async throws -> InstalledPackageInfoplus shared errorInstalledPackagesRepositoryError.packageNotFound(kind:name:).- Default protocol behavior: repository extension falls back to
loadInstalledPackages()+ section/name lookup so existing test doubles remain source-compatible until they adopt specialized implementations. BrewInstalledPackagesRepository: narrow read now executesbrew info --json=v2 --formula|--cask <name>and maps only the requested section (formulaeorcasks) by exact name/token.- Tests: added dedicated lookup coverage in
BrewInstalledPackagesRepositorySinglePackageTestsand new command-fixture helperInstalledPackagesTestSupport.packageInfoJSONResponse(...).
2026-05-06 — Installed row-driven catalog patch after upgrades
- Row ownership:
InstalledListRowViewModelnow owns mutableInstalledPackageRowsnapshot state (beyond phase) so UI labels can update from narrow refreshes without full-list reloads. - Refresh trigger: Row VM watches
phaseChanges(for:)and performs a narrow repository refresh when a row operation transitionsrunning -> idle, then emitsonRowUpdatedfor parent catalog merge. - View wiring:
InstalledPackagesViewwiresInstalledListRowRootwith explicit closures (refreshedInstalledRow,mergeInstalledRow) so row refresh coordination remains in view/view-model boundaries rather than nested VM factories. - Detail upgrade path:
InstalledViewModelonUpgradeSuccessnow refreshes and merges only the selected row instead of calling full-listrefreshInstalledPackagesPreservingUI().
2026-05-06 — Installed refresh simplification (full background snapshot)
- Reverted the row-level refresh/patch architecture to reduce complexity:
InstalledListRowViewModelnow observesphaseChanges(for:)for progress only, and no longer owns row-refresh callbacks or repository fetch logic. - Upgrade completion now uses
InstalledViewModel.refreshInstalledPackagesPreservingUI()as the single refresh path (full snapshot, no.loadingtransition), preserving smooth list UX without skeleton flicker. - Kept DI/wiring improvements: view-layer wiring still creates/injects child VMs (
InstalledColumnsfor details VM,InstalledPackagesViewfor row roots / command-center environment injection); list VM does not create child VMs. - Removed narrow single-package repository API (
loadInstalledPackage(kind:named:)) and dedicated lookup tests introduced solely for the abandoned row-refresh approach.
2026-05-06 — Concurrency isolation policy (Swift 6 default actor isolation)
- Project build setting
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActoris enabled for the app target, so non-UI infra code can require explicit actor-neutral annotations. SwiftLintruleunneeded_synthesized_initializeris disabled in.swiftlint.ymlto allow intentional explicit emptyinit/deinitdeclarations when carrying concurrency-isolation intent.- Preferred style: use member-level
nonisolatedfirst (for initializers/factories/helpers/protocol requirements), and avoid type-levelnonisolatedunless a full type-level actor-neutral contract is clearly needed.
2026-05-07 — Installed model unification to BrewPackage
- Collapsed Installed feature data models into a single domain model:
BrewPackagenow backs list rows, detail payloads, and repository contracts. - Repositories now map
brew info --json=v2through sharedBrewInfoJSON+Mappinghelpers and returnBrewPackagevalues (InstalledPackagesRepositoryreturns[BrewPackage],PackageDetailsRepositoryreturnsBrewPackage). - Installed list/detail presentation formatting moved to feature view models (
InstalledListRowViewModel,InstalledDetailsViewModel) plusInstalledBrewVersionFormatting; models are now presentation-agnostic. - Removed legacy Installed models (
InstalledPackageInfo,InstalledPackageRow,InstalledPackageDetails) and dead parser path (InstalledPackagesParser+ parser tests).
2026-05-08 — Installed feature single-source-of-truth
InstalledViewModelowns the catalog and observesBrewCommandCenter.allPhaseChanges()to refresh after mutating operations complete (running → idle).- Detail and row view models no longer fetch from a repository;
PackageDetailsRepositoryand related infrastructure are removed. - Detail and list rows stay in sync by propagating injected
BrewPackagefrom the parent viaonChange(of: package)→update(package:)on the child view models. - Detail-upgrade phase observer caveat: the detail VM still polls phase around submit rather than subscribing to a stream for concurrent operations on the same id; acceptable for now.
2026-05-08 — Root-view dependency ownership policy
- Feature
*Rootviews are the dependency composition boundary for that surface: they read app-level dependencies (for example@Environment), construct/inject content-view dependencies, and own view-model lifecycle boundaries. - Content views should receive dependencies from their root and focus on rendering and behavior; avoid direct app-level dependency acquisition in content views when a root wrapper exists.
- Treat optional content view models introduced solely to compensate for misplaced dependency acquisition as an anti-pattern.
2026-05-08 — Installed list scroll preservation policy
- Keep the Installed list mounted under a stable parent container (
HSplitView) across selection changes; switching between list-only and split layouts can remount the list and reset scroll position. - Prefer native
List(selection:)for Installed row selection state, with view-model-backed selection binding (setSelection(_:)) and row tags by stable package id.
2026-05-11 — Installed detail uninstall actions
- Installed detail mutations now support both upgrade and uninstall through the shared
BrewCommandCenterpipeline; uninstall usesPackageUninstallCommandwith newBrewOperationKindcases (uninstallFormula,uninstallCask). InstalledPackageDetailViewnow treats the footer as a general package-actions area: show upgrade chrome only whenpackage.outdated, but always show uninstall chrome with a native confirmation dialog and copyable user-facingbrew uninstall ...command.
2026-05-13 — Vale docs job and docs/Gemfile
- Symptom:
vale docs/fails withlstat docs/../Gemfile: no such file or directorywhendocs/Gemfileis missing. - Cause: Vale 3 treats
RakefileandBrewfile(among others) as Ruby-format prose under[formats] rb = mdin.vale.ini. For those paths it expects a resolvableGemfilenext to the Ruby project layout; this repo haddocs/Gemfile.lockand Jekyll binstubs but nodocs/Gemfile, unlikeHomebrew/brewwheredocs/is a full Jekyll tree includingGemfile. - Fix: Commit
docs/Gemfile(matching upstream brew docs, consistent with the lockfile) anddocs/.ruby-versionso Vale and laterbundle execsteps in.github/workflows/docs.ymlboth succeed.
2026-05-13 — Installed inventory cache
- Cache: In-memory
InstalledInventoryCacheactor storesInstalledInventorySnapshot(packages +PackageDependencyGraph) populated byBrewInstalledPackagesRepositoryafter successfulbrew info --installed --json=v2.BrewAppcreates one cache per app lifetime and injects it via SwiftUI environment (InstalledInventoryEnvironment.swift); feature roots constructBrewInstalledPackagesRepository/BrewInstalledDependentsRepositoryfrom that shared cache. Previews and tests construct isolated caches withInstalledInventoryCache()when needed. - TTL: Snapshots are stale after 3600 seconds;
load()may return cached packages when fresh;refresh()passesforceRefresh: trueto bypass TTL after mutating brew work. - Used by: Detail dependents come from reverse dependency edges among installed packages; per-selection
brew useswas removed.
2026-05-15 — Installed inventory visibility (tests-only pass)
- Visibility: New inventory types are module-internal; graph storage and JSON mapping helpers are
private. ProtocolsInstalledDependentsRepository/InstalledInventoryReadingstay internal as DI boundaries. - Follow-up (production, separate PR):
InstalledInventoryCache.packages()has no call sites (prefercachedPackages()).EmptyInstalledDependentsRepository/EmptyInstalledInventoryReadingare unused in production — used only frommakeInstalledDetailsViewModelin BrewTests; decide whether to delete, wire in previews, or keep for tests only. - Tests:
makeInstalledDetailsViewModelinInstalledDetailsViewModelTestsSupportsupplies empty repo defaults for non-inventory tests; production inits remain four explicit parameters.
2026-05-15 — Dead-symbol cleanup applied
- Removed dead installed-inventory API surface from app target:
InstalledInventoryCache.packages(),InstalledInventoryReading.installedPackages(for:),BrewInstalledPackagesRepository.installedPackages(for:), andBrewPackage.reference. - Moved test-only empty inventory/dependents stubs out of
Brew/RepositoriesintoBrewTests/TestSupport(EmptyInstalledInventoryReading,EmptyInstalledDependentsRepository) so production DI paths remain explicit. - Pruned unused test support helpers
localizedHomebrewCommandFailedMessage()andpackageInfoJSONResponse(...)fromInstalledPackagesRepositoryTestSupport.
2026-05-16 — Domain/presentation mapping boundary
- Installed uninstall presentation mapping now uses a feature-layer
UninstallPackageIteminitialized fromBrewPackage, instead of adding uninstall UI properties directly onBrewPackage. - Team convention clarified: domain model types stay presentation-agnostic; map to UI properties through feature ViewModels (top-level surfaces) or feature
*Itemtypes (subview/action presentation).
2026-05-17 — Passive view enforcement for presentation state
- Strengthened
CONVENTIONS.mdand.cursor/rules/swift-implementation.mdcwith an explicit MVVM guardrail: views must not compose multiple ViewModel state primitives inline to derive a single presentation decision. - Preferred pattern: expose one derived ViewModel property per UI concern (for example one spinner-driving busy flag), and have the view bind directly to it.
2026-05-18 — Installed detail itemization boundary
- Installed detail now uses feature-layer item mappings for co-changing presentation groups:
PackageDetailMetadataItem,UpgradePackageItem, andUninstallPackageItem, all exposed fromInstalledDetailsViewModelfor VM-driven subviews. - For this surface, independently changing async state streams (
isUpgrading,isUninstalling,isMutatingPackage) remain on the top-level ViewModel and are not folded into item types. - Detail-presentation extensions on
BrewPackagewere removed (BrewPackage+Presentation.swiftdeleted); presentation mapping lives in ViewModel/feature item types only.
2026-05-18 — Installed naming consistency (minimal pass)
- Installed detail ViewModel naming now aligns with the package-detail view family:
InstalledDetailsViewModelwas renamed toInstalledPackageDetailViewModeland moved toInstalledPackageDetailViewModel.swift. - Feature-local helper names should stay scoped but respect lint type-length limits (
SwiftLinttype_namemax 40): renamed detail command console helper toInstalledDetailMutationConsoleand mutation-parity test suite/file toInstalledDetailMutationParityTests. - Installed list row presentation value type renamed from
RowVersionPresentationtoInstalledListRowVersionPresentationto keep local naming explicit.
2026-05-18 — Folder boundary reorganization
- Feature folders now use explicit subdirectories:
Features/<Feature>/ViewsandFeatures/<Feature>/ViewModels. Models/is now enforced as domain-only; non-domain types moved out:- Installed presentation/UI types moved to
Features/Installed/ViewModels. - Brew command JSON/operation helpers moved to
Services/BrewCommand(with command JSON underServices/BrewCommand/JSON). - Installed inventory snapshot moved to
Services/InstalledInventory.
- Installed presentation/UI types moved to
- Service infra is now grouped by boundary: brew command layer in
Services/BrewCommand, inventory infra inServices/InstalledInventory. - Added durable guidance in
CONVENTIONS.md,ARCHITECTURE.md, and.cursor/rules/folder-boundaries.mdcso future agents keep the same placement policy.
2026-05-18 — Centralized preview support policy
- Shared preview samples and lightweight preview fakes now live in a single source of truth:
Brew/PreviewSupport/AppPreviewSupport.swift. - Previews should consume centralized support types (
AppPreviewSupport, preview fakes) instead of defining one-off inline mock services/repositories per view. - Preview blocks are colocated at the bottom of their view files; standalone
+Previews.swiftfiles for those views were removed. - Enforcement guidance is documented in
CONVENTIONS.mdand.cursor/rules/previews-centralized.mdc(linked fromswift-implementation.mdc).
2026-05-18 — Package display labels vs canonical IDs
BrewPackagenow carriesdisplayNamefor UI labels while keepingnameas the canonical Homebrew identifier used for IDs and CLI commands.- Installed mapping from
brew info --json=v2now uses richer display fields with fallback:- formula:
full_name(fallbackname) - cask: first
nameentry (fallbacktoken)
- formula:
HomebrewPackageReferenceremains identity-first (.formula(name:)/.cask(token:)) and still uses canonical values forpackageID; dependency-only contexts may continue showing token/name when richer metadata is not present.
2026-05-18 — PR descriptions location preference
- User preference: place generated PR descriptions in
.ai/scratchpad.mdby default.
2026-05-18 — PR description project skill
- Added project skill at
.cursor/skills/pr-description-to-scratchpad/SKILL.md. - Skill contract: build PR bodies from
main...HEAD, follow.github/PULL_REQUEST_TEMPLATE.md, and append to.ai/scratchpad.md.
2026-05-18 — Discover analytics data-access slice
- Added a dedicated Homebrew analytics network boundary:
BrewAPIClientprotocol +URLSessionBrewAPIClientinBrew/Services/BrewAPIClient.swift.- Endpoint-specific APIs for Discover:
fetchFormulaInstallOnRequestAnalytics(window:)fetchCaskInstallAnalytics(window:)
- Added resilient analytics decoding model
BrewAnalyticsJSON:- tolerant top-level decode with
formulae/casksbucket fallback - count parsing supports both numeric and comma-separated string forms
- normalized
BrewAnalyticsPackageCountoutput for repository mapping
- tolerant top-level decode with
- Added
DiscoverPackagesRepository+BrewDiscoverPackagesRepositoryreturning one combinedDiscoverTopPackagesSnapshot(topFormulae+topCasks) with limit/window parameters (defaults: top 10, 30d).
2026-05-18 — Package-domain lookup identity rule
- Package-domain representations should expose
HomebrewPackageReferenceas their lookup identity (formulae map to.formula(name:), casks map to.cask(token:)). BrewPackagenow exposesreferenceagain as a computed property fromkind+name.- Discover models now carry typed references:
BrewAnalyticsPackageCount.referenceDiscoverTopPackage.reference
- Added
.cursor/rules/package-domain-reference.mdcand mirrored the rule inCONVENTIONS.mdto keep the requirement persistent for future domain models.
2026-05-18 — URLSessionProtocol testing seam for API client
- Added
URLSessionProtocol(data(for:)) inBrew/Services/URLSessionProtocol.swiftwithURLSessionconformance. URLSessionBrewAPIClientnow supports dependency injection viainit(session: any URLSessionProtocol, ...), enabling deterministic unit tests with a mock session implementation instead of closure-only stubbing.
2026-05-18 — Discover analytics strict decoding policy
- Discover analytics decoding now fails fast for required non-optional fields rather than defaulting to empty/zero values.
BrewAnalyticsJSONrequires validcategory,total_items,total_count,start_date,end_date, and at least one analytics bucket container (formulaeorcasks).- Malformed per-entry
countvalues now throw during decode (no fallback0), and unresolved package references are treated as decode failures instead of being silently filtered out.
2026-05-18 — Discover analytics decoder simplification
- Simplified
BrewAnalyticsJSONstrict decoder by removing fallback identity inference:- no fallback from bucket key
- no fallback from
categoryinferred kind
- Analytics rows now require explicit identity in payload (
formulaxorcask), which keeps failure behavior deterministic when server payloads are incomplete/ambiguous.
2026-05-18 — API client tests use URLProtocol stubs
- Replaced
URLSessionProtocolabstraction tests with integration-style tests that use a realURLSessionconfigured withURLProtocolstubbing. URLSessionBrewAPIClientsession init now takes concreteURLSession.BrewAPIClientTestsuse host-scoped stub queues/recording inStubURLProtocolto keep tests deterministic under concurrent execution.
2026-05-18 — Deferred Installed search adaptation
- Installed search still filters on canonical
BrewPackage.namerather thandisplayName. - User requested this remain unchanged for now and be revisited during discovery search work.
- Keep this as an explicit follow-up so label-search behavior can be aligned intentionally later.
2026-05-18 — Catalogue decode strictness
- Catalogue transport decoding (
FormulaCatalogueJSON/CaskCatalogueJSON) now treatsdesc,homepage,versions.stable, andanalytics.install["30d"]as required fields. - Catalogue array decoding is now lossy per item: invalid entries are skipped, and decode failures are captured with item index + error string so callers can handle partial failures without dropping valid items.
2026-05-19 — Catalogue cache + repository split
- Split catalogue responsibilities into two boundaries:
CatalogueCacheactor owns only in-memory state, disk persistence, and ETag storage.BrewCatalogueRepositoryowns TTL, stale-while-revalidate policy, refresh orchestration, and in-flight task deduplication.
CatalogueCachecurrently preloads cache on async init:init(...) asyncloads formula/cask cache files immediately (missing/corrupt files are swallowed);- read/write methods operate on preloaded in-memory state (no separate warm-up method).
BrewCatalogueRepositorybehavior:- one refresh
Taskper catalogue kind is shared across concurrent callers; - stale cached data returns immediately while background refresh runs;
- cold start (no cache) blocks on refresh and surfaces errors;
304updates only last-refresh timestamp;- background refresh failures are swallowed.
- one refresh
2026-05-19 — Transport models must not cross repo/service APIs
CatalogueRepositorynow maps cached/network catalogue transport payloads to domainBrewPackagearrays before returning.- Durable rule:
Codabletransport types (*JSON, DTO/wire models) must stay behind service/repository boundaries and not appear in service/repository protocol return types. - Enforcement/docs:
- Added
CONVENTIONS.mdnote under Transport boundary. - Added
.cursor/rules/codable-boundary.mdc(always apply) and linked guidance in.cursor/rules/swift-implementation.mdc.
- Added
2026-05-19 — Installed model split (InstalledBrewPackage vs BrewPackage)
- Installed-only state moved out of shared package domain:
InstalledBrewPackagecomposes aBrewPackage(package) plusinstalledVersionsandoutdated.- Public fields shared with
BrewPackageare exposed as wrapper properties; identity (id,reference) derives frompackage. BrewPackageis slim/shared for catalogue/discover-prep fields only.
- Boundary rule:
- installed repositories/inventory/graph/view-model surfaces use
InstalledBrewPackage, - catalogue repository surfaces remain
[BrewPackage]and must not reintroduce installed-only fields.
- installed repositories/inventory/graph/view-model surfaces use
- Command/convenience identity behavior remains stable via
kind:nameIDs:BrewOperationID(package:)now takesInstalledBrewPackage,HomebrewPackageReferencegainedinit(installedPackage:)(delegates toinit(package:)),- package ID/reference semantics remain canonical and unchanged.
2026-05-20 — Discover wrapper domain contract
- Discover top-list domain rows now use
DiscoveryBrewPackage(package: BrewPackage+thirtyDayInstallCount) instead of reference/count-only payloads. DiscoverTopPackagesSnapshotnow carries[DiscoveryBrewPackage]for formula/cask sections; downstream list/detail mapping should read package metadata fromDiscoveryBrewPackage.packageand ranking fromDiscoveryBrewPackage.thirtyDayInstallCount.
2026-05-20 — Discover repository owns catalogue enrichment
BrewDiscoverPackagesRepositoryfetches analytics viaBrewAPIClient, then resolves each ranked analytics row throughCatalogueRepository.package(for:)(per-reference lookup, not whole-catalogue loads).CatalogueRepositorypublic API ispackage(for: HomebrewPackageReference) -> BrewPackage?; whole-catalogue fetch/map/cache refresh stays private insideBrewCatalogueRepository.- On
package(for:), if the kind’s catalogue is uncached or TTL-stale,BrewCatalogueRepositoryfetches the full catalogue once, updates cache, and serves lookups from an in-memory ID index (deduped in-flight refresh preserved). - Analytics rows with no catalogue match are dropped silently; the limit applies to matched rows only.
DiscoverViewModelno longer loads catalogues; it consumes enrichedDiscoveryBrewPackagerows from the discover repository.
2026-05-21 — Installed and Discover list selection UI
- Selection chrome: Both
InstalledPackagesViewandDiscoverPackagesViewuse a plainList(notList(selection:)), row taps via.onTapGesture+viewModel.setSelection, and.listRowBackground(selected ? Color.brewBrandTint : Color.clear). AvoidList(selection:)— it applies system accent selection on top of the brand tint. - Column backgrounds: List column header, list body, and detail inherit window/split chrome; do not wrap Discover/Installed columns in
.background(Color.brewSurface)unless product asks for explicit surface fills everywhere. - Scroll: Both lists use
ScrollViewReader+scrollToSelectionon appear/selection/row-id changes (.brewFastanimation).
2026-05-27 — Console command-job projection layer
- Console UI is fed by a registry, not by
BrewCommandCenterdirectly.JobRegistry(@Observable @MainActor) projects center operation state for console views; it never mutates the center. Subscribes toallPhaseChanges()once at app launch viaBrewApp .task { jobRegistry.startObserving(commandCenter) }. Injection mirrorsBrewCommandCenterEnvironment—@Entry var jobRegistry: JobRegistry. CommandJobreusesBrewOperationID(do not mint a parallel UUID). Carries phase, output buffer (50k-line cap, FIFO eviction), and a derivedexitCode: Int32?.isTerminal == (exitCode != nil).- Exit code derivation from phase transitions (since
BrewOperationPhasedoesn't carry one):.running → .idle⇒ exit 0;.failed(.brewCommand(exitCode, _))⇒ that exit code; other.failed(...)cases ⇒-1sentinel. An.idle → .idle(initial replay or noop) does not back-fill an exit code. - Job materialization is lazy and ID-derived. Registry only creates a
CommandJobwhen it sees a.running(kind)phase for an unknown id; the user-facing command string andJobScopeare synthesized fromBrewOperationID.rawValue(parsed as"<kind>:<name>") plus the runningBrewOperationKind. This avoids extendingBrewMutatingCommandwith metadata for slice 1. JobScope:.package(name:)for per-package ops,.globalforbrew update/doctor/cleanup,.batch(names:)for multi-package upgrades. Used to filter jobs in detail panes vs show globally in the console.- Files live in
Brew/Features/Console/Models/(BrewCommandOutputLine,JobScope,CommandJob,JobRegistry,JobRegistryEnvironment). Future console UI will live alongside inBrew/Features/Console/Views/.
2026-05-27 — Output streaming in BrewCommandCenter
BrewCommandCenteraddsoutputChanges(for:)andallOutputChanges()mirroring the existing phase API shape exactly — per-id stream yields buffered-on-subscribe lines, all-output stream yields each new(id, line)with no replay. Both clean up viaAsyncStream.Continuation.onTermination.- Output sink plumbing uses a TaskLocal (
BrewCommandOutputContext.sink) rather than threading a closure throughBrewMutatingCommand/BrewCommandRunning.SerialBrewCommandCenter.submit(id:command:)sets the sink viawithValue(...)for the duration of the submit;BrewCommandServicereads it once at the top ofrun(...). Commands are untouched — they still callcontext.commandRunner.run(...). BrewCommandServicestreams via chunkedavailableData+ manual\nsplitting, notFileHandle.bytes.linesand notreadabilityHandler. Rationale: preserves byte-exactCommandOutput.standardOutput/standardError(the existing buffered path's contract — tests rely on it). Whensink == nilthe splitting is skipped (no overhead). Lines whose bytes are not valid UTF-8 are dropped from the stream (still kept in the verbatim byte return).- Ordering across actor boundary uses an internal
AsyncStreambuffer. The non-actor sink closure yields lines to a continuation; a per-submit drain Task pulls them in FIFO order onto the actor and broadcasts to per-id and all-output listeners. Drains untillineContinuation.finish()is called when the submit task settles. Across stdout/stderr the order is inherently non-deterministic (separate POSIX streams). NoopBrewCommandCenter/RecordingSerialBrewCommandCenterstub or forward the new methods. Output streams fromNoopBrewCommandCenterfinish immediately (no work to observe).- No 16ms batching yet — each line emits to listeners as it arrives. Sufficient for typical brew chatter; batching deferred to console-polish (Slice 6).
2026-05-27 — Console UI surfaces (collapsed strip)
- Root layout:
MainWindowViewwrapsNavigationSplitViewin aVStack(spacing: 0) withDivider() + ConsolePanelat the bottom. The console spans the full window width (under sidebar + detail) — IDE convention, signals that it reflects all brew activity. @SceneStorage("consoleExpanded")stores the expand/collapse boolean per window. Survives quit-and-relaunch. Do not use@AppStorage(cross-window pollution).- Console layout tokens live in
BrewLayout:consoleCollapsedHeight(36),consoleMinExpandedHeight(120),consoleMaxExpandedHeight(600),consoleDefaultExpandedHeight(240). No new color literals at call sites — all colors flow throughBrewColors(brewTerminal,brewBrandPrimary,brewStatusSuccess,brewStatusError,brewTextSecondary,brewTextTertiary,brewCodeDefault,brewCodeError). - MVVM separation:
ConsoleStatusPresentation.from(registry:)projects the registry into a view-passive struct (DotState + Summary + isRunning); the view renders that snapshot. Mirrors the existing*BusyPresentationpattern in Discover/Installed. - Phase short labels live as
BrewOperationPhase.shortLabelextension (done/running/failed). Coarser than brew stdout markers (fetching/pouring/linking) because the center's phase enum doesn't expose those — sub-phase granularity would require stdout parsing. Single source of truth for status-bar + future inline-card text. - Status dot palette (design-system rule — BrewUI-owned components use amber for in-progress; terminal states use semantic colors): running =
brewBrandPrimary, succeeded =brewStatusSuccess, failed =brewStatusError, idle =brewTextTertiary. - Slice 3 ships the collapsed strip only. The expand toggle is wired but the expanded body comes in slice 4 — the chevron currently toggles state with no visible effect.
2026-05-27 — Expanded console body, toolbar, resize, and shortcut
ConsolePanelis a state machine. Collapsed → justConsoleStatusBar. Expanded →VStackofConsoleResizeHandle+ConsoleToolbar+Divider+ConsoleBody. Animates.frame(height:)with.brewFast(0.15s easeOut) to keep theListabove from clipping (see ARCHITECTURE/CONSOLE_IMPLEMENTATION_PLAN §7).- Resize state:
@SceneStorage("consoleHeight") consoleHeight: Double = consoleDefaultExpandedHeight. Per-window, survives quit-and-relaunch. Drag is clamped betweenconsoleMinExpandedHeight(120) andconsoleMaxExpandedHeight(600). The handle's cursor usesNSCursor.resizeUpDown.push()/.pop()inonHoverso it doesn't leak when the view disappears mid-hover. - **
⌘\`` toggle usesFocusedBindingplumbing.**FocusedValues.consoleExpanded: Binding?is published byMainWindowViewvia.focusedSceneValue(.consoleExpanded, $consoleExpanded);ConsoleCommandsreads it with@FocusedBinding(.consoleExpanded)` so the menu command targets the active window's scene-storage. The menu button label flips between "Show Console" / "Hide Console" based on the current state; disabled when no focused window publishes the value. - Commands are added in two
.commandsblocks onWindowGroup— the always-onConsoleCommandsand (DEBUG-only)DebugMenuCommands. Two adjacent.commandsmodifiers merge; this avoids#if DEBUGinside a@CommandsBuilder. - Toolbar = selected-job pill + actions + collapse chevron. Pill =
ConsoleStatusDot+ monospace command on abrewBrandTintrounded rect (sm radius). Save usesNSSavePaneldefaulting to~/Downloadswith filenamebrewui-<sanitized-command>-<yyyy-MM-dd-HHmmss>.log; Copy usesNSPasteboard.general. Both consumeCommandJob.formattedOutputForExport()which[stderr]-prefixes error lines so a downstream reader can disambiguate. - Clear calls
registry.clearCompleted()directly with no confirmation dialog.clearCompletedalready preserves in-flight jobs, so there's no destructive case to confirm — deviation from CONSOLE_IMPLEMENTATION_PLAN §3.4 which suggested a dialog when running jobs exist. Revisit if user feedback wants the call-out. ConsoleBodyis aListoverselectedJob.outputwith.listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.brewTerminal). Auto-pin-to-bottom on.onChange(of: job.output.count). User scroll-lock when scrolled up is deferred to console polish. stderr lines usebrewCodeError; stdout usesbrewCodeDefault. Text uses.textSelection(.enabled)so individual lines can be copied. Empty state =ContentUnavailableView(macOS 14+).- No ANSI / color parsing in v1 — raw monospace text. Tracked for the polish slice.
2026-05-27 — Console split into BrewCommandJobsRepository + ConsoleViewModel (supersedes earlier registry note)
- Supersedes the 2026-05-27 "Console command-job projection layer" entry.
JobRegistryand\.jobRegistryno longer exist. The decision to call the layer a "Registry" pre-dated a full survey of the codebase's MVVM + Repository conventions; the conflation of translation/cache, screen-local state, and presentation projections inside one type drifted from how Discover/Installed are structured. - New layer split mirrors
BrewInstalledPackagesRepository+InstalledViewModel:BrewCommandJobsRepository(Brew/Repositories/,@Observable @MainActor, conforms toCommandJobsObserving) is the single source of truth for cached command-center operation state. Subscribes tocommandCenter.allPhaseChanges()and.allOutputChanges()ininitvia@ObservationIgnoredTasks (nostartObserving(_:)call fromBrewApp— pattern matchesBrewInstalledPackagesRepository.completionObserverTask). Ownsjobs/orderedIDs; exposesremove(id:)andclearCompleted().handlePhase/handleOutputare internal seams kept around for unit tests.ConsoleViewModel(Brew/Features/Console/ViewModels/,@Observable @MainActor) owns the per-windowselectedID(screen state — does not belong on a Repository). Exposes derivedorderedJobs,selectedJob,activeJob,statusPresentation, andjobs(for packageName:). Intent methods (select,dismiss,clearCompleted) clean up selection before forwarding to the repository.
- Wiring:
\.commandJobsRepositoryenv key replaces\.jobRegistry.ConsolePanelRootreads the env value and constructs a@StateConsoleViewModelper-window (mirrorsInstalledColumnsRoot).ConsolePanel/ConsoleToolbar/ConsoleBody/ConsoleStatusBarconsume the VM directly (@Bindable/let) — no environment-key reads inside console views. - AppKit isolation:
NSSavePanel/NSPasteboardcalls live in a view-layer namespaceBrew/Features/Console/Views/ConsoleOutputExport.swiftrather than in views or the VM. Rule: ViewModels do not import AppKit; AppKit bridges sit at the view layer.CommandJob.formattedOutputForExport()and.suggestedExportFilename()remain on the domain model (pure data). - File placement:
CommandJob.swiftmoved fromConsole/Models/toConsole/ViewModels/— it's@Observable @MainActorwith presentation-shaped helpers, which CONVENTIONS.md says doesn't belong underModels/.Console/Models/now holds only the pure value typesBrewCommandOutputLineandJobScope. - Tests:
BrewCommandJobsRepositoryTestscovers cache mechanics (phase/output materialization,remove,clearCompleted).ConsoleViewModelTestscovers selection/projection/intent forwarding.ConsoleStatusPresentationTestsconstructs a(repository, viewModel)fixture and asserts againstviewModel.statusPresentation.
2026-05-29 — BrewOperationID generalized for maintenance work (Doctor feature)
BrewOperationIDis now an enum (BrewCore/Operations/BrewOperationModels.swift):.package(HomebrewPackageID)and.maintenance(token:displayCommand:). Was a package-only struct. Maintenance ops (abrew doctorfix likebrew link/brew cleanup) aren't package-scoped, so they carry their owntokenfor identity plus the user-facingdisplayCommand. Backward-compatible: the existinginit(kind:name:)/init(package:)/init(packageID:)inits still build.package, so all install/upgrade/uninstall call sites were untouched.packageIDis now an optional accessor (nilfor maintenance).BrewOperationKind.doctorFixadded.CommandJob.materializebranches on the id: package ids synthesize the command from kind+name (existing path); maintenance ids use the id's storeddisplayCommandverbatim. This is why a doctor fix shows up correctly in the bottom console with no extra wiring.DoctorFixCommand(BrewCLI) runs an arbitrarybrewargv withoperationKind = .doctorFix; vended via the newBrewMutatingCommandFactory.doctorFixCommand(arguments:)port (Live/Stub/Unimplemented impls updated).DoctorRepositoryis an app-scoped@Observable @MainActorport (mirrorsInstalledInventoryObserving/BrewInstalledPackagesRepository), NOT a stateless one-shot — it holdsstate: LoadState<DoctorReport, any Error>+isRefreshingand exposesload(). Long-lived (injected once inBrewApp) so the report persists across leaving/returning to the Doctor tab.load()is stale-while-revalidate: an existing report stays on screen (isRefreshingflips on) while the re-check runs; only the first load shows.loading; a failed refresh keeps the prior report (logs). Runsbrew doctorread-only (via the command center); a non-zero exit means warnings were found, not a failure, so the exit code is ignored and both stdout+stderr are parsed. Concurrentload()s coalesce onto one in-flightTask.- Follow-up (still open): migrate install/upgrade/uninstall display strings to read from the command rather than re-synthesizing in
materialize, now that maintenance ops carrydisplayCommand.
2026-05-30 — Doctor parser overhaul + CommandBlockView + console pill
DoctorIssueis modeled as an ordered list of typed blocks (Sources/BrewCore/Models/DoctorReport.swift):severity,blocks: [DoctorBlock](with.prose/.command/.data/.linkcontent), per-blockcaption+precededByBlankLine, andrawBodyas the verbatim fallback.- Block classification is by first member, not per-line allowlist (per the addendum). A colon-terminated un-indented line opens a pending block; its first indented member decides whether the whole block is
.command/.data/.link. The allowlist is consulted once per block (first-member command test) plus as a.prosefallback for stray commands under prose. - Run Fix is intentionally narrow.
DoctorBlock.isRunnableis true only for a single-step, non-adminbrewcommand (multi-step andsudoblocks are copy-only). CommandBlockView(Sources/BrewUIComponents/Views/CommandBlockView.swift) replacesPackageDetailCommandConsole— same single-command API plus acommands: [String]init that renders a numbered list with one "Copy all". Used by Installed (upgrade + uninstall), Discover (install), and Doctor (per fix sequence). The old name is gone.brew doctorruns throughBrewCommandCenterviaBrewOperationKind.doctorRead+ aDoctorReadCommandactor (Sources/BrewCLI/).BrewDoctorRepositorynow takescommandCenter: any BrewCommandCenterand submits a stable maintenance id (token "doctor",displayCommand "brew doctor") on everyload()— the bottom console picks up the pill + streams output from the existingBrewCommandJobsRepositoryprojection, and re-submits on the same id update the existingCommandJobinstead of spawning new pills.DoctorReadCommandis an actor because it owns mutablecapturedOutputmutated byrun(in:)and read by the repo after submit returns;nonisolated let operationKindsatisfies theBrewMutatingCommandrequirement. This loosens the center's "mutating only" framing — documented at the enum case. The serial queue is shared so doctor reads queue behind active mutations; acceptable.
2026-06-02 — Doctor severity model: Tier 1 is unreachable by construction
DoctorSeverity.infodoes not exist. The enum iscaution / danger / unsupportedonly. Tier 1 is unreachable from anyWarning:block in realbrew doctoroutput: Homebrew'ssupport_tier_message(Ruby) starts withreturn if tier.to_s == "1", so a Tier 1 configuration produces no callout text at all. "Tier 1" means "fully supported, no editorial needed" — brew doesn't print anything about it. The parser's regex is intentionally narrowed to/This is a Tier ([23]) configuration:/to make this guarantee visible at the source.- Why this is a source-level guarantee, not a fixture-coverage gap: when the
.ai/plans/brew-doctor-console.txtfixture (which is the "all possible warnings" sample) produces no Tier 1 callouts, the first instinct is "fixture is incomplete." It isn't — there is no such callout anywhere in brew's source to capture. Don't re-add.infothinking the next fixture expansion will exercise it. If anyone wants a Tier 1-style "info" affordance in the UI, it has to come from a different source (e.g. anadd_info/ohaicapture frombrew config), not the doctor warning stream. .unsupportedis still real.support_tier_messagedoes emit "This is an Unsupported configuration:" for theunsupportedslug (prerelease / outdated-macOS paths incheck_for_unsupported_macosresolve there). The fixture happens not to include such a case; the unit testunsupported configuration trumps any tierexercises the parser branch.
2026-06-23 — Brew is spawned through the user's login + interactive shell
- The problem. A GUI process launched from Finder/Dock/launchd inherits a stripped environment: missing
PATHentries andHOMEBREW_*vars thatbrew shellenvinstalls into the user's profile files (~/.zprofile). Runningbrew config/brew doctoragainst that stripped env produced output that didn't match what the user sees in Terminal, which destroys trust in the diagnostics. - The fix. All brew invocations now route through
LoginShellBrewCommandRunner(Sources/BrewCLI/), which rewritesrun(executableURL: brew, arguments: [...])into<login-shell> -l -i -c "<brew> <quoted args>". The login shell is resolved byLoginShellResolverfromgetpwuid(getuid())->pw_shell(Directory Services) with a/bin/zshfallback.$SHELLis intentionally not consulted — it can be stale or absent in a GUI launch. - Why
-lAND-i.-lsources.zprofile/.bash_profile(Homebrew's documented install writesbrew shellenvto.zprofile).-iadditionally sources interactive rc files (.zshrc/.bashrc) so users who put their brew setup in those files also get parity. The tradeoff is real and accepted: interactive rc files may print banners or expect a TTY (we run with/dev/nullstdin, so any TTY-dependent code in rc files will surface as warnings on stderr). If we ever see that as a meaningful noise source for users, the dial is-lonly. - Single spawning route. Quoting is handled by
LoginShellBrewCommandRunner.singleQuoted(POSIX'…'with'\''for embedded apostrophes), so package names and flags survive the shell parse intact. The three productionlive()factories (BrewCommandExecutionContext.live(),BrewConfigRepository.live(),BrewInstalledPackagesRepository.live()) all now constructLoginShellBrewCommandRunner(). Tests still useBrewCommandService()directly for raw-process plumbing tests — that's correct; the shell wrap is a live-wiring concern. - Out of scope (tracked separately).
brew.envfile parsing andBrewEnvFileLocatorassume brew sees the same world the user does, which they now do — but parser/locator changes are their own work.
2026-08-05 — UI-test identity module + composition-root seams (PR 1 of the UI testing plan)
BrewAccessibilityIDis the single source of truth for testable element identity, superseding the never-createdUtilities/AccessibilityIdentifiers.swiftnoted in the 2026-03-01 entry. It is a dependency-free SwiftPM target linked by both the app and theBrewUITeststarget (Homebrew.xcodeproj→packageProductDependencieson each), so an identifier string is spelled exactly once, inAXID.rawValue. Views attach identity with.axid(_:)(Sources/BrewUIComponents/Views/View+AXID.swift), neveraccessibilityIdentifierwith a literal.Tests/BrewAccessibilityIDTestspins the wire format so drift breaks a unit test rather than a UI test.- Parameterised cases carry the package token (
installedRow(token:),discoverRow(token:)) — the token is the Homebrew name/token thatHomebrewPackageID.nameyields, so rows are addressable without label or index matching. .searchablefields cannot carry a custom accessibility identifier. SwiftUI injects the field into the window toolbar; an.axidat the call site lands on the modified content and overwrites the screen's own identifier.AXID.installedSearchField/.discoverSearchFieldtherefore exist but are unattached — queryapp.searchFieldsuntil the field is a custom view.- Two process-boundary seams, both public and both inert in production:
URLSessionBrewAPIClient.stubbed(protocolClasses:baseURL:)sets protocol classes on one ephemeral session's configuration (neverURLProtocol.registerClass, which would also capture.shared), andBrewCommandExecutionContext.uiTesting(brewURL:)uses the realBrewCommandServicewithBrewExecutableLocator(overrideURL:)and deliberately noLoginShellBrewCommandRunner— wrapping a fake brew in the user's login shell would re-introduce dotfile dependence (see 2026-06-23). BrewApp.init()branches once, onBrewUITestingLaunchConfiguration.current(), which returnsnilunless the-uiTestinglaunch argument is present. Both seams are a singleguardaway from the untouched.live()wiring; being in UI-test mode is never threaded further into the app.
2026-08-08 — Page Object Model + deterministic UI suite (PR 2 of the UI testing plan)
- The suite mocks two process boundaries and nothing else. Every layer of our own code runs for real: the real
BrewCommandServicespawns a real subprocess and drains real pipes; the realURLSessionBrewAPIClientbuilds requests, negotiates ETag/304, decodes and caches. That is what makes error cases worth writing — a 500 becomesBrewAPIClientError.httpStatusthrough the actual client, not a stubbed error value. - One fixture tree feeds both seams.
FakeBrew.install(scenario:)writes a per-run temp directory containing<scenario>/brew(fake-brew fixtures) and<scenario>/http(response bodies), and hands the app the paths via launch environment. The HTTP responder lives in the app (BrewUITestingStubURLProtocol), not inBrewUITests: aURLProtocolregistered in the test target runs in the test process and would never see the app's traffic. PR 2's plan offered both wirings; this is the one chosen. - HTTP fixtures are addressed by request path,
/→_(/api/formula.json→api_formula.json), with optional sibling.statusand.etagfiles. A matchingIf-None-Matchgets a real 304, so the client's conditional-request branch is exercised rather than stubbed away. - fake-brew is a lookup table, not a
caseover subcommands. Files are<argv joined by _>.stdout/.stderr/.exitcode/.next-info. Adding a command to a scenario is adding a file..next-infois the one piece of state: on a successful mutating run it becomes the answer to every laterbrew info, which is what lets an uninstall actually remove the row (the repository force-refreshes off the command center's running→idle transition and must see a changed world). - PR 1 left two repositories outside the shell seam.
BrewInstalledPackagesRepository.live()andBrewConfigRepository.live()each constructed their ownLoginShellBrewCommandRunner+BrewExecutableLocator, so the Installed list and Configuration tab ran real brew under-uiTesting. Both now take aBrewCommandExecutionContext, andBrewAppbuilds exactly one context for the whole process.live()still means the same wiring it always did. BrewCommandExecutionContext.uiTesting(brewURL:)now takes an optional.nilinstalls a locator that always throwsBrewLookupError.executableNotFound— that is how thebrewNotFoundscenario is expressed, and it also closes a hole: a-uiTestinglaunch that named no fake used to fall through to.live()and could have driven the developer's real Homebrew.- UI-test runs are state-isolated even though PR 2's plan defers "state isolation" to PR 3.
CatalogueCache/DiscoverAnalyticsCacheare given the run's scratch container and aUITesting.-prefixedUserDefaultsnamespace (cleared at launch). Without this, run N's fixture catalogue decides run N+1's behaviour and the fixtures overwrite the real app's Application Support cache on the same machine. PR 3's isolation is about real-brew/real-network runs; this is the minimum that makes "deterministic across ≥20 runs" mean anything. - The console's expand/collapse state is read from the toggle button's accessibility label ("Show console" when collapsed, "Hide console" when expanded — exactly one is mounted). The app auto-expands the panel by itself when a command starts, so a read-then-decide-then-click sequence races it;
ConsoleScreen.setExpanded(_:)waits for the state it wants before concluding it needs to click. AXIDgained the shared failure chrome (errorState/errorRetryButtononAsyncContentView's error branch,brewNotFoundStateon Configuration). It is screen-agnostic by design — every loadable surface renders the same view — so screens scope the query to their own root to say which surface failed.- Upgrades rows got their own identity (
upgradesList/upgradesRow(token:)) rather than reusing the Installed row ids. The same package is legitimately in both lists at once, and sharing ids would let an Upgrades assertion be satisfied by an Installed row.
2026-08-08 — UI-test fixtures travel in the launch environment, not on disk
- The failure. The obvious design — the test runner writes a fixture tree, the app reads it — needs one directory two different processes are both allowed to use, and on this machine there isn't one.
FileManager.temporaryDirectoryresolves per process, so the runner's temp dir gave the appEPERMwhen it tried to spawn the fakebrewfrom it;/private/tmpthen gave the runnerEPERMcreating directories. Neither cause was ever established (the runner hasENABLE_APP_SANDBOX = NOand Xcode'sXCTRunner.apptemplate carries no entitlements), and two rounds of guessing at the OS policy cost more than the redesign. - The fix is to delete the question.
BrewUITestContract(a dependency-free SwiftPM target linked by both the app andBrewUITests, exactly likeBrewAccessibilityID) carriesBrewUITestingFixturePayload— a bag of relative paths to bytes plus which one is the executable — JSON, raw-DEFLATE, base64, passed in one launch-environment variable.BrewUITestingFixtureInstallerwrites it into the app's own temp directory at launch, chmods the fake, andsetenvs the resulting paths so the stubURLProtocol(which runs onURLSessionthreads) and the fakebrewsubprocess (which inherits its environment) can both find them. One process creates the files, runs them, and owns them. - The payload is budgeted, not unbounded.
BrewApp.launchrejects anything over 512 KB; the tree compresses ~20× because fixture JSON is highly repetitive, so the large-inventory scenario lands well under it. If a scenario ever breaches the budget, shrink the scenario — the environment is shared with everything else the launch needs. XCUIApplication.launch()does not reliably foreground the app on macOS. The runner keeps focus, and a backgrounded app's window has an empty accessibility tree — so every element query fails with "does not exist" and the only cure is clicking the Dock icon.BrewApp.launchcallsactivate()and waits for.runningForeground. This is load-bearing, not cosmetic.- Element-not-found failures carry a diagnosis.
BrewUITestDiagnosticsreports whether the app is running, foregrounded, has a window, and which identifiers are actually in the tree. "Expected installed.screen to exist within 60s" is true and explains nothing; distinguishing "never opened a window" from "wrong identifier" is the difference between a five-minute fix and a day. - Assertions gated on a subprocess use the command timeout, not the render timeout.
DoctorReport.placeholder.isHealthyisfalse, so whilebrew doctoris in flight the Doctor screen shows the redacted issues skeleton and the healthy text does not exist yet. Same for Configuration, whose cards only exist oncebrew confighas been parsed.
2026-08-18 — Live end-to-end canaries (PR 3 of the UI testing plan)
- Tier 3 is a contract canary, not coverage.
BrewUITests/E2E/launches the app with no-uiTestingargument, soBrewApp.init()takes.live()on both seams — real login shell, realbrew, realURLSession.shared, real bottles from ghcr.io. Its job is to go red when Homebrew changes its JSON shape or CLI output while the fixture-fed Tier 2 suite stays green. It runs on theBrew-E2Etest plan (scripts/test-e2e) via.github/workflows/e2e.ymlon every pull request, plus manually before a release, and is skipped by name inBrew-UI.xctestplanso a live failure never reads as a failure of the deterministic suite. Both plans hang off the oneBrew-UIscheme;xcodebuild -testPlanneeds the plan listed there. - State isolation is a split of responsibilities, not a sandbox. Tests arrange and clean up by shelling out to the machine's real brew (
E2E/Brew.swift, aProcesshelper that is a fixture actuator and never the code under test) and act through the UI with PR 2's page objects unchanged. A broken arrange then reads as a fixture failure instead of as a red assertion inside the flow under test.Brew.runthrows with brew's own output attached;Brew.forceUninstallis best-effort because uninstalling what isn't installed exits non-zero, which is what "already clean" looks like. hellois the canary package and this suite owns it. GNU Hello: no dependencies, pours in seconds, and nothing on a developer machine legitimately depends on its presence. Force-uninstalled insetUpand intearDown(aftersuper.tearDown()has terminated the app, so cleanup never contends with a running app for Homebrew's lock). Nothing else in the repo may depend on it being installed or absent.- Assertions are shape, not values. A row for the package exists; a
HOMEBREW_VERSIONrow is present; install/uninstall are presence/absence transitions with a console that streamed non-empty output ending in success. No version, count, or machine-specific path is asserted — a formula bump must not turn a canary red, or it gets muted within a week. - Real lists virtualize, so live assertions filter first. On a machine with hundreds of formulae the Installed row for
hellomay never render, andwaitToExistwould fail on a package that is genuinely installed. The live tests type into the search field before asserting on a row. The deterministic suite never had to care — its fixtures are short. - Determinism environment travels through the launch environment.
Brew.determinismEnvironment(HOMEBREW_NO_AUTO_UPDATE,_NO_ANALYTICS,_NO_INSTALL_CLEANUP,_NO_ENV_HINTS) is set onXCUIApplication.launchEnvironmentand on the fixture actuator's own subprocess.BrewCommandServiceruns withEnvironment.inherit, and a login shell adds to exported variables rather than clearing them, so they survive-l -i. If a dotfile is ever found to stomp one, the fallback is an-e2eargument that makesBrewCommandServicemerge the keys explicitly (the same hook that conditionally sets colour env) — not needed so far. - upgrade and doctor are excluded by design. Upgrade needs a deterministically outdated installed package, which a live machine cannot guarantee without brittle bottle pinning; doctor's output is entirely machine-state dependent, so it has no stable happy path. Both stay at Tier 2 with fixtures. Error cases stay at Tier 2 too — at Tier 3 an "error" is usually an outage.
2026-08-27 — The console assembler keeps a window of revisable rows, not a single line
- The bug. On a multi-cask
brew upgradethe console repeated the whole download block every tick instead of updating it. Homebrew's parallel download queue (download_queue.rb) draws N rows, deliberately leaves the last one without a newline, then rewinds withTty.move_cursor_up_beginning→ESC[<n>F(CPL).TerminalLineAssemblerhandledm/K/G/C/Dand dropped everything else, so the rewind was lost. Both reported symptoms came from that one gap: the block repeated, and the newline-less last row spliced into the next frame's first row. - A single line of cells was the wrong shape, and the old doc comment said so. The fix keeps
windowDepthrows of history addressable rather than only the row being written. Depth = the pty's row count, because that is the screen — a terminal cannot address above it. Brew's block ismin(concurrency, Tty.height)andTty.heightreads that same pty (PseudoTerminal.defaultRows, 40); default concurrency isCPU cores × 2, so 40 always covers it. - The non-obvious part is
\n. Inside a block it must step the cursor down (decrementrowCursor, reset column), not open a new row — only atrowCursor == 0does it commit. Without that the window fills with duplicates and nothing is actually fixed. windowDepthis injected, not read.TerminalLineAssembleris inBrewCore;PseudoTerminalis inBrewCLI, which depends onBrewCoreand not the reverse.BrewCLIpasses its own value in;defaultWindowDepthmirrors it with a comment saying why it can't reference it.- Rows carry a serial so revisions coalesce per row across a chunk even when a commit shifts every position along. Offsets count from the end, which is what keeps them valid after
CommandJob.maxOutputLinestrims the front. - Over-scroll degrades to appending rather than clamping. A move above the window is ignored — the pre-window behaviour, which looks wrong but never writes to the wrong row. Deliberate escape hatch for sequences this model doesn't cover (
ESC[H,ESC[J, scroll regions, alt screen — brew's download queue uses none of them). - Trailing-newline-ness is a property of how a row ended, not of
isComplete. Conflating them regressedBrewCommandServicePseudoTerminalTests: a child that ends withprintf 'tty'(no newline) must not gain one instandardOutput.TerminalTranscripttracksendsWithNewlineseparately so the returned transcript holds settled rows without inventing terminators. - Rejected: adopting SwiftTerm. Its
Terminalmodels a fixed grid + scrollback; the console models a variable-length transcript with stable row identities for SwiftUI diffing. Using the engine headless means diffing a grid to infer which rows changed — plausibly more code than this, and it fightsCONVENTIONS.md's "add packages sparingly". Revisit only if the app ever needs to run arbitrary interactive commands (a shell, a pager, anything using the alt screen), where hand-rolling stops being viable.
2026-08-30 — Colour tokens are held to WCAG AA by a calculated audit
- The audit is a unit test over the asset catalogue, not a rendering check.
Tests/BrewUIComponentsTests/Support/parsesMedia.xcassetsfrom source and computes WCAG 2.1 relative luminance / contrast ratios. It reads the JSON rather than resolvingNSColor(named:bundle:)because SwiftPM copies.xcassetsinto the test bundle uncompiled — there is noAssets.car, so a runtime lookup silently returnsnilunderswift test. Do not "fix" the test by switching it toNSColor; it will pass vacuously. - The (foreground, background) pairs are hand-maintained, deliberately. A full cross-product would demand success green stay legible on the error tint, which no screen renders, and buying that costs real chroma across the palette. When a view puts a token on a new surface, add the pair to
BrewColorTokenContrastTests.requirements. - Translucent background tokens are measured over both
SurfaceandSurfaceElevated. The dark-modeBrandTintis 12% alpha, so what a selected row's text actually sits on depends on the panel behind it; the worse of the two has to pass. This is what forced the last few tenths on tertiary text and dark-mode error red. - Brand amber is split by role.
color.brand.primarystays the undiluted Homebrew amber and is fill-only — the pairing that must hold for it is darkcolor.text.onBrandknocked out of the fill. Amber drawn on an app surface uses the newcolor.text.brand(Color.brewTextBrand), which is darkened to#98620Fin light mode because the brand amber is 2.4:1 on white. Reaching forbrewBrandPrimaryas aforegroundStyleis the mistake this split exists to prevent. CommandBlockViewdeliberately does not useColor.brewTerminal. Light-on-dark text under the system selection highlight is 1.01:1, and SwiftUI exposes no way to restyle that highlight. The command well isbrewSurface(the sidebar's surface) withbrewTextPrimary; the header and footer arebrewSurfaceRecessed. Selection is therefore dark-on-light, which the system draws correctly, and moving the block back to the terminal surface reintroduces the defect. The console hit the same wall and solved it the other way, with anNSTextView(see 2026-08-31) — that route would allow the terminal look here at the cost of owning the text view.- Vivid colours survive as fills and glyphs, not as text. After the AA pass the warning colour was too dark to read as a warning, so it split the same way the brand amber did:
color.status.warning(#9B600D) is text-only,color.status.warningBold(#F0AD4E) is icons, dots and filled badges. The bold yellow is deliberately 1.95:1 as a bare glyph on white — under WCAG 1.4.11's 3:1 for non-text — because none of those glyphs carry meaning alone; each sits beside text saying the same thing. Do not "fix" it back. - A filled surface is audited by what is knocked out of it, not by the fill.
BrandPrimary, its hover/pressed states andStatusWarningBoldare asserted againstTextOnBrand. - The package-kind icon chip is an outline, not a fill. The accent draws the glyph and a 1pt ring; the row shows through. A filled version was tried and rejected — it was the third arrangement of this chip, after a pale tint that did not separate from the row. Both parts are non-text graphics, so the chip is held to 3:1, not 4.5:1; it clears 4.5 anyway because the accent doubles as the kind badge's label colour.
- There are two palettes, and only the high-contrast one is held to WCAG AA. Colour sets carry High Contrast variants, which macOS swaps in on System Settings → Accessibility → Display → Increase contrast. The standard palette is the Homebrew palette as designed and several pairings sit below 4.5:1 on purpose — do not "fix" them.
BrewColorTokenContrastTestsasserts AA in the high-contrast appearances and, for the standard ones, only that high contrast never renders a pairing worse. - AppKit's fallback for a missing high-contrast variant is not observable with the system setting off. A probe that resolves
NSColor(named:)underNSAppearance(named: .accessibilityHighContrastAqua)returns the standard value, so it looks like a confirmed fallback order when it has confirmed nothing. Every colour set that varies by luminosity therefore states its high-contrast dark value outright, enforced by a test, and the reader only ever does exact-match-or-universal. Verify variants withassetutil --infoon the compiledAssets.car, not with a runtime probe. - Borders are knowingly outside the audit.
BorderDefault/Strong/Separatorsit at 1.1–1.7:1 against their surfaces. WCAG 1.4.11 wants 3:1 only for boundaries required to identify a control; these are decorative card edges, and raising them is a visible restyle rather than a conformance fix.
2026-08-31 — The console body is a text view, not a list of rows
- What was wrong. A
Listwith oneTextper line gives every line its own selection scope. Dragging across lines selected nothing, ⌘A had no document to select, the gaps between rows (row insets, and the empty area below the last line) weren't text at all so clicks there did nothing, and the pointer alternated between an arrow and an I-beam depending on which of those it was over. All four are the same defect: there was no document. NSTextViewis the fix, and it stays inside View + ViewModel.ConsoleTextView(anNSViewRepresentableinBrewFeatureConsole/Views/) hosts a TextKit 1 text view — selectable, not editable,isRichText = falseso ⌘C yields plain text.ANSIConsoleTextnow rendersNSAttributedString; colours are still named on the SwiftUI token palette and bridged withNSColor(_:), so the design system remains the source and light/dark still resolve. A colour-scheme flip re-renders the whole document (tracked with@Environment(\.colorScheme)as a stored dependency — readingcontext.environmentinsideupdateNSViewisn't a reliable invalidation trigger).- Streaming is a diff, not a rebuild.
ConsoleTranscript(ViewModels) holds the rendered lines plus their cached UTF-16 lengths and returns the minimal edit — every changebrewmakes lands in a suffix, so it is "replace from the first line that differs to the end". Rebuilding per line would be quadratic over a run and would drop the user's selection each time. Lines compare on stream + text only; identity is deliberately ignored because a redrawn progress row keeps its id (see 2026-08-27). - The renderer and the transcript must agree character-for-character. Offsets address the rendered text, so
ConsoleTranscript.text(of:)andANSIConsoleText.attributed(for:)both emit spans-joined +"\n". A unit test pins the invariant (rendered length == transcript.length) because nothing else would catch drift. - Follow-the-tail is owned by a scroll observer, not by the update.
ConsoleOutputTextViewgives up following when the user scrolls away and takes it back when they return to the end — growing the document doesn't move the clip view, so the bounds-change notification only ever fires for a real scroll. The pin also runs fromlayout(): SwiftUI hands over the first batch of output before the scroll view has any size, and a scroll issued then goes nowhere (symptom: opening the console on a finished job showed the top of the log). - Verified by driving the real app (DYLD-injected driver, see the 2026-08-25 note): drag across lines selects four lines;
selectAll:validates on the first responder and selects the whole document; hit-testing the bottom edge of the output area lands on the text view (no arrow-cursor dead zone); a 200-character selection survives ~1100 characters of streamed output; scrolling away leaves the reader in place. Menu-routed ⌘A could not be exercised — a bare exec never becomes the key app — so that path rests on the responder validatingselectAll:.
2026-08-31 — The outdated check answers from brew info, which brew never auto-updates
brew infois not an auto-update command.Library/Homebrew/utils/auto-update.shlistsinstall,outdated,upgrade,bundle,release(plustapwith args).brew info --installed --json=v2— the app's only source of outdated state — is not among them. With the JSON API in play that is harmless: brew re-fetchesapi/formula.jws.jsonon its own TTL whenever a command reads it. UnderHOMEBREW_NO_INSTALL_FROM_APIthere is no such refresh — formula and cask data come from tap git clones, so the app reported whatever the taps held the last time the user ranbrew updateby hand, forever.BrewInstalledPackagesRepository.updateTapsIfNeedednow runsbrew update --auto-update --quiet(the same commandbrew upgraderuns) ahead of the info fetch, on Homebrew's own 300s interval for this mode.- The app cannot read
HOMEBREW_*from its own process. It is launched by Finder, not from a shell, so a profile-exported variable is invisible toProcessInfowhile being fully in effect for every brew invocation the app makes throughLoginShellBrewCommandRunner(see 2026-06-23).BrewConfigEnvironmentReaderasks brew instead:brew configprintsHOMEBREW_NO_INSTALL_FROM_API: setonly when it is set, so presence of the row is the signal. Probed once per process — changing it means editing a shell profile, which does not take effect for a running app anyway — and it falls back to the API path wheneverbrew configcannot be run or exits non-zero. - A failed tap update is deliberately not fatal. The taps keep their previous contents, and the
brew infofetch is what decides whether the check produced an answer at all. The attempt is timestamped whether or not it succeeded, so a persistently failingbrew updatecannot stall every fetch behind it (each mutating operation reconciles with a forced fetch). statealone cannot express "the check failed". The repository deliberately keeps cached packages.loadedwhen a refresh fails, so a surface reading onlystatepresents a stale zero as fact — the app claiming "everything is up to date" when it never found out.InstalledInventoryObserving.refreshFailurecarries the last failure, cleared only by a fetch that completes: clearing it inapply(_:)would have let a cache-first repaint (which fetches nothing) silently turn "couldn't check" back into "nothing to upgrade".- A revision bump moves
revision, notversions.stable. Readingversions.stablealone made the Upgrades tab advertise a target identical to the installed keg (ffmpeg9.0.1revision 1 rendered "v9.0.1 → v9.0.1").HomebrewPkgVersionrenders Homebrew's ownPkgVersion(version_revisionwhen the revision is non-zero) and both thebrew infomapping and the formula catalogue go through it.
2026-09-06 — Self-upgrade mechanism (UI, detection, helper handoff and the real upgrade)
- The app's own cask token is
homebrew-app(bundle idsh.brew.app).SelfUpgradeIdentity(Sources/BrewCore/Models/) owns it along with the homepage and the"brew upgrade --cask homebrew-app"display command, which the banner shows as the Upgrade button's tooltip — the app does not hide what Homebrew is about to do. - Detection reuses the installed inventory, not a second brew call. The app ships as a cask, so it already appears in
brew info --installed --json=v2with anoutdatedflag.BrewSelfUpgradeStatusProviderfinds the cask by token and surfacesSelfUpgradeStatus.isUpgradeAvailableis that flag verbatim — not re-derived by comparing version strings; there is no semver comparator in the codebase.SelfUpgradeStatusis a domain model, so it is notCodable. - The verb is "Upgrade" everywhere, for the app as for packages. An earlier pass used "Update" for the app to mark it as a different kind of thing; it read as an inconsistency. Copy formatting lives in
SelfUpgradePresentation, unit-tested in isolation. SelfUpgradeCoordinatoris the one object the banner binds to, composing the status provider,SelfUpgradePreferencesand theSelfUpgradeHandoff. Owns banner visibility (hidden whiledismissedVersion == latestVersion, so dismissal is per-version and a newer release re-shows it; an upgradebrewreports outdated with no version string is dismissed for the session only, since there is no version to key a stored dismissal to),beginUpgrade(), and the one-timelastLaunchOutcomeacknowledgement. There is no auto-update-on-launch and no preferences UI — both were tried and removed.- The preference keys are prefixed under
-uiTesting, like the caches. They first shipped unprefixed inUserDefaults.standard, so a UI test dismissing the banner wrote into the real app's preferences.UserDefaultsSelfUpgradePreferencesandSelfUpgradeLaunchNoticeboth take adefaultsKeyPrefix(default"selfUpgrade", reproducing the original key names) fed through the samedefaultsKeyPrefix(base:fixtures:)helper as the caches. Any newUserDefaults-backed state needs the same seam. - The whole flow runs from the banner — there is no detail pane. Upgrade and Later sit on the banner itself, so nothing has to be navigated to and nothing competes with the package selection for the detail column. A pane was built (
SelfUpgradeDetailView, plus aSelfUpgradeDetailSelectionarbitrating "package or the app itself?" becauseactiveSelectedPackageIDhas no empty state, plusselectNext/Previous(hasVisibleSelection:)so the arrows entered from the list's edge) and all of it went: putting two buttons on the banner deleted the entire problem. The extraction ofPackageDetailHero/Badge/MetadataRow/LinkRowintoBrewUIComponentswent with it, having had exactly one consumer. - The banner is pinned above the Installed, Upgrades and Discover lists, outside the filtered dataset so it survives the All/Formulae/Casks scopes and search. Being on lists owned by two different features is why it is its own module,
BrewFeatureSelfUpgrade— neitherBrewFeatureInstallednorBrewFeatureDiscovercan own it, and the module deliberately does not depend on either. \.selfUpgradeCoordinatoris the only self-upgrade env key, defined inBrewFeatureSelfUpgrade(notBrewAppEnvironment, which can't reference the feature type).BrewAppinjects it;SelfUpgradeBannerreads it itself rather than being handed it, so placing the banner is one line per list and no feature threads a coordinator through its columns. There are deliberately no\.selfUpgradeStatusProvider/\.selfUpgradePreferenceskeys — a key with no reader is dead wiring.brew upgrade --cask homebrew-appis aBrewCommandvalue, not a command type.BrewCommands.selfUpgrade()builds the argv plusBrewOperationKind.upgradeApp, andHelperSelfUpgradeHandoffputs that argv in the spec. It never goes through the command center, so there is deliberately noBrewOperationIDcase and noselfUpgradeCommand()factory method for it — both were built, neither was ever called, and dead operation plumbing is what the Periphery gate exists to catch.- The app's own cask is kept out of every list, count and batch.
InstalledInventoryObserving.userManagedPackagesfilters it;outdatedPackages,outdatedCountand both list view models read through that, whilestatestays unfiltered soBrewSelfUpgradeStatusProvidercan still find it. Bulk upgrade needs its own guard:brew upgradeandbrew upgrade --caskname no packages, soUpgradesViewModel.upgradeSelectionfalls back to.explicit(rows)whenever the app's cask is outdated and the scoped selection would cover it. Left visible it offered a one-click in-process upgrade of the running bundle — exactly what the handoff exists to avoid. - An app cannot replace its own bundle while running, so the upgrade is a handoff.
HomebrewUpgradeHelperis a new Xcode target bundled atContents/Helpers/, launched byHelperSelfUpgradeHandoffjust beforeNSApplication.terminate(in that order — the helper polls for the pid to disappear). Ordering inside the helper is wait for exit → upgrade → record → relaunch, owned bySelfUpgradeHelperRunin the package target with the AppKit andUserDefaultscalls behind injected effects so the sequence is unit-tested. A wait that times out abandons the run entirely — no upgrade, no notice, no relaunch. The timeout means the app is by definition still on screen, so relaunching would put a second copy of it there, and there is no next launch to acknowledge anything on. The contract travels as a JSON file path, not argv: a UI-test relaunch environment carries the whole fixture tree and would blowARG_MAX.BrewSelfUpgradeContractis dependency-free on purpose, so nothing in the app's module graph can leave the helper unable to bring the app back. - The outcome crosses the process boundary through
SelfUpgradeLaunchNotice, written by the helper into the app's defaults suite by name (the helper's ownUserDefaults.standardis a different domain) and consumed inBrewApp.initbefore the caches are built, sincemakeCatalogueCachesweeps everyUITesting.-prefixed default. A failed attempt gets its own alert rather than silence — the app relaunches looking identical either way. - The upgrade invocation travels in the spec; the helper resolves nothing.
brewExecutablePathcomes from the app's locator (executionContext.brewExecutableURL()) andupgradeArgumentsfromBrewCommands.selfUpgrade(), so the helper cannot guess a prefix and upgrade the wrong Homebrew, the displayed command and the executed argv are one value, and a missingbrewfails the handoff before the app quits rather than after.upgradeEnvironmentexists because the fakebrewreads its fixture tree from the environment, and the helper is spawned by the app but outlives it — whatsetenvpublished cannot be inherited. SelfUpgradeRunnerlives inBrewSelfUpgradeHelperCore, a package target, purely so it can be tested. The helper is an Xcode command-line target with no test host. It runs brew throughBrewCommandRunningon the pipe path, superseded below (2026-09-09) from a hand-rolledFoundation.Process.- The transcript goes to a file, truncated per run (
~/Library/Logs/sh.brew.app/self-upgrade.log, or the run's container under-uiTesting). The app is not running while the upgrade is, so stderr goes nowhere anyone will look and a failed self-upgrade would have no account of itself. - Nothing about the upgrade is simulated any more (superseded 2026-09-08, below). The helper only ever runs the real
brew upgrade --cask;.selfUpgradeRunsBrewand.selfUpgradeBrewFailsdrive it against the fakebrewand assert on which outcome alert comes back.
2026-09-07 — Storage roots are split by recoverability, namespaced by bundle identifier
- The app is unsandboxed, so
~/Libraryis shared ground. There is no container to namespace writes, and a folder namedBrewcollides with anything else of that name and reads as thebrewCLI's. Every store resolves its own path undersh.brew.app. - Which root follows from whether the contents can be rebuilt. The catalogue and Discover analytics caches hold ETag-validated HTTP responses, so they sit in
Caches: purgeable, out of Time Machine, one refetch to replace. Crash reports sit inApplication Supportbecause a pending report cannot be regenerated. Do not merge the two back into one root. Logstakes the same namespace, so the self-upgrade transcript is~/Library/Logs/sh.brew.app/self-upgrade.log. It wasLogs/Homebrewon the reasoning that the app's log belongs beside brew's own; that is thebrewCLI's directory, and the rule above is what settles it. The pattern is<root>/sh.brew.app/…for every root the app writes to —Caches,Application Support,Logs, and anything added later.
2026-09-08 — The simulated upgrade is gone, and both outcomes share one alert
simulatedUpgradeDurationis deleted from the contract, the helper and the handoff. It let the helper sleep instead of upgrading, for the DEBUG menu's "Skip the Real Upgrade" and for the.selfUpgradeAvailableUI test. A path that steps over the subprocess proves nothing about the subprocess, so the acknowledgement test now runs against.selfUpgradeRunsBrew— the realbrew upgrade --cask homebrew-appagainst the fakebrew— and the duplicate success-only test went with it.BREW_UITEST_FAKE_SELF_UPDATE,BrewUITestScenario.usesFakeSelfUpgradeandBrewUITestingLaunchConfiguration.usesFakeSelfUpgradeare gone too.- The DEBUG menu keeps one toggle, "Show the Self-Upgrade Banner", and it cannot upgrade anything. A dev build is not the installed cask, so
DebugSelfUpgradeHandoffrefuses the handoff while the simulated banner is on — the alternative is quitting a debug build to upgrade the copy in/Applications. The refusal surfaces as the banner's own failure message, which is whereSelfUpgradeCoordinatorputs a handoff error. - SwiftUI presents only the first
.alertattached to a view.MainWindowViewhad one for each outcome; the failure alert was unreachable, so a failed self-upgrade relaunched the app silently andSelfUpgradeUITests.testAnUpgradeThatExitsNonZeroIsReportedOnTheNextLaunchfailed — confirmed by running it, with the success test green in the same run. One.alert(_:isPresented:presenting:)now carries both, with the copy inSelfUpgradeOutcomePresentation(unit-tested) rather than inline literals. Watch for: a second.alert(or.confirmationDialog) on the same view anywhere else is the same latent bug.
2026-09-08 — The vocabulary is "upgrade", never "update"
brew updaterefreshes taps;brew upgradeinstalls newer versions. The app's self-upgrade runsbrew upgrade --cask homebrew-app, so "update" was the wrong word in every name it appeared in. Renamed wholesale: the targetsBrewFeatureSelfUpgrade,BrewSelfUpgradeContract,BrewSelfUpgradeHelperCoreand the Xcode targetHomebrewUpgradeHelper(bundle idsh.brew.app.upgradehelper), everySelfUpdate*type, and the members carrying the verb —performUpgrade(),beginUpgrade(),isUpgradeAvailable,acknowledgeUpgradeCompletion().- What kept the word "update":
updateNSView,observeRowUpdates, the caches'update…methods, andbrew update --auto-updateinBrewInstalledPackagesRepository. Those genuinely refresh something rather than install a newer version. - The rename moves persisted state. The defaults keys are now
selfUpgrade.dismissedVersionandselfUpgrade.lastUpgradeOutcome, the transcript is~/Library/Logs/sh.brew.app/self-upgrade.log, and the accessibility identifiers areselfupgrade.*. A dismissal recorded by a pre-rename build is forgotten once, which re-shows the banner; there is deliberately no migration for a pre-release preference. - The outdated badge says "Upgrade available". Its accessibility label was the last "Update" in user-facing copy;
brewcalls the package outdated and the action an upgrade, and the badge should not introduce a third word for it.
2026-09-08 — The self-upgrade banner is composed by the app shell, through a slot
- A feature never imports another feature.
BrewFeatureInstalledandBrewFeatureDiscovereach importedBrewFeatureSelfUpgradeto placeSelfUpgradeBanner()at the top of their own list — a horizontal dependency between siblings. Composition belongs one level up: the shell already imports every feature to build the split view, so it is the only place allowed to know both exist. - The seam is
\.packageListBannerinBrewAppEnvironment— aPackageListBannervalue wrapping a@MainActor () -> AnyView, in the same shape asRefreshAllActionand\.navigateToInstalledPackage.MainWindowViewfills it withPackageListBanner { SelfUpgradeBanner() }; the three lists renderpackageListBanner()at the top of their column and cannot name what is in it. The default is empty, so previews, unit tests and any tab the shell does not fill it for get nothing. AnyViewis unavoidable here and is the first in the codebase. An environment value cannot carry an opaque type. It is one banner, rebuilt only when the coordinator's observable state changes. The alternative — threading a@ViewBuilderparameter throughInstalledUpgradesRoot/DiscoverColumnsRootdown to the lists — adds a generic parameter to six view types and a supply obligation to every call site and preview..safeAreaInset(edge: .top)on the feature column was tried and rejected. It is the obvious "wrap without the view knowing" mechanism, but the shell's feature column is the whole detail area, so the banner spanned the list and the detail pane and covered the top of both. The slot puts it back inside the list column where it belongs.PackageListBannerSlotTestsmeasures the delta, not the structure. Hosting each list with a fixed-height probe in the slot and asserting the fitting height grows by exactly that much is what catches a slot that is read but never placed — which still compiles.UpgradesChromeBudgetTestskeeps its test-only dependency onBrewFeatureSelfUpgrade: the banner's height still comes off the Upgrades list, soBrewLayout.mainPaneMinHeighthas to clear both, and a test target composing two features is not the coupling the rule is about.
2026-09-09 — The self-upgrade runs brew the way the rest of the app does
- The helper spawns through
BrewCommandRunning, notFoundation.Process.SelfUpgradeRunnerhad its ownProcess, pipe drain, line splitter and timeout;BrewSelfUpgradeHelperCorenow depends onBrewCLI+BrewCoreand hands(brew, argv, BrewRunOptions)to a runner, on.pipes(forceColor: false). The Xcode helper target needed no new product dependency — the local package linksBrewCLItransitively. - The login shell was the requirement this path was missing. Every other brew invocation goes through
LoginShellBrewCommandRunner(2026-06-23) because a Finder-launched process has none of theHOMEBREW_*a profile exports. The helper is spawned by the app, so it inherits the same stripped environment, andbrew upgrade --cask homebrew-appwas the one command running without the user's mirror, proxy,HOMEBREW_GITHUB_API_TOKENorHOMEBREW_CASK_OPTS.SelfUpgradeHandoffSpec.usesLoginShellnow carries the app's own.live()/.uiTesting(brewURL:)choice across the process boundary, andSelfUpgradeRunner.init(usesLoginShell:)picks the same runner the app would. Under-uiTestingit stays false — wrapping the fakebrewin the developer's dotfiles is the thingBrewCommandExecutionContext.uiTestingexists to avoid. - Signalling only
brewcould hang the helper forever.Processgives the child no session, so the old runner SIGKILLedbrewalone and then waited for end-of-input on a pipe still held bycurl/git/ruby. Reproduced: the drain never ends, so no outcome is recorded and the app is never brought back — the exact outcomeupgradeTimeoutexists to prevent.BrewCommandService'screateSession+gracefulShutDown(toProcessGroup: true)teardown takes the whole tree, and gives brew two seconds of SIGTERM first rather than killing it mid-dittoof an app bundle. The timeout is now task cancellation racing the run, and the loser is awaited so brew is gone before the relaunch. BrewRunOptions.environmentis how the fake brew's fixture tree gets pinned. It merges over.inheritafter the colour variables the output channel sets, andLoginShellBrewCommandRunnerforwards it to the shell, which exports it on to brew.Environment.Key(rawValue:)is the only public way in from a runtime string.- The handoff refuses while a mutating brew command is in flight.
NSApplication.terminatewould kill the subprocess the command center is streaming and the helper would start a second brew against the same Homebrew;HelperSelfUpgradeHandoffaskscommandCenter.runningPhases()first and throws, which surfaces on the banner.BrewOperationKind.isMutatingis the filter —doctorReadis the one scheduled kind that changes nothing, and it runs long enough that counting it would block every upgrade attempted while the Doctor tab is loading. - The runner takes an injected
sleep, likeSelfUpgradeHelperRun. Wall-clock timeouts of 0.5–3s pass alone and fail under the parallel suite, because the fakebrewhas not spawned before the deadline — which made the tests that matter most (a descendant surviving the timeout) pass vacuously. Tests drive the timeout off a readiness file the fakebrewtouches.
2026-09-13 — Sidebar icons use SF Symbols
- Sidebar rows render
Image(systemName:)glyphs, not emoji (issue #165). macOS convention; symbols tint with selection state and align viaBrewLayout.sidebarIconWidth.
2026-09-14 — Actionable issue forms and crash-report compatibility
- Bug and feature forms in
.github/ISSUE_TEMPLATE/adapt Homebrew/brew's workflow to the macOS app. Questions and general opinions go to Homebrew Discussions; reports request concrete behaviour and relevant app diagnostics. - Keep blank issues enabled while released apps use
CrashReportIssue'sissues/new?title=…&body=…URL. Disabling that route needs a coordinated move to the bug form's field parameters, including a plan for reports from older app releases. - Keep the PR template short. Request validation appropriate to Swift and UI changes, with actual results and any checks not run.
check-issues.ymlandcheck-prs.ymladapt Homebrew/brew's template enforcement and use the sharedHomebrew/.githubchecker. They read templates frommainthrough the API without checking out code, close stripped templates and reopen only submissions closed by this automation. The checker requires at least 75% of template headings/checkboxes, regardless of tick state, plus an AI mention for PRs. Legacy app-generated crash reports remain accepted.- Template warning comments mark unresolved failures and are removed once the template is complete, after any required reopen succeeds. Clear them for already-open submissions too: the stale workflow shares
github-actions[bot], so a historical warning cannot identify which workflow performed a later closure.
2026-09-15 — Before and after PR screenshots
- The PR template requires before and after screenshots for visible changes, with a comparison table. Changes with no visual impact must explain why screenshots do not apply.
2026-09-15 — Homebrew uses isolated system zsh
- Supersedes the login-shell policy from 2026-06-23 and 2026-09-09.
ZshBrewCommandRunneralways launches/bin/zsh --no-rcs --no-global-rcswith an explicit environment. Login-shell discovery and startup-output filtering are removed. PATHcontains the located brew executable's directory followed by/usr/bin:/bin. Identity, home and temporary directories come from Foundation; shell exports, includingHOMEBREW_*andXDG_CONFIG_HOME, are not inherited. Homebrew loads user settings frombrew.envitself. The README and Configuration tab explain migration and relaunching after edits because the API-mode probe is cached.- Stock zsh always executes
/etc/zshenv. A secondenv -iafter startup clears its exports before brew. Both environment assignments and brew arguments travel as literal argv, never interpolated shell code. App-owned output controls and explicit fixture variables are retained. - Self-upgrades use the same runner by default; the handoff no longer carries a shell-selection flag. Deterministic UI tests still invoke the fake executable directly to inherit their fixture environment.
- Live E2E launch variables no longer configure the app. CI writes the deterministic settings into
~/.homebrew/brew.envon its ephemeral runner; manual-run guidance is inBrewUITests/E2E/README.md. Never run the live suite unasked.
2026-09-15 — Filter unavoidable zsh startup output
- Restores startup-output filtering for
/etc/zshenv: clearing its exports cannot prevent banners from corrupting JSON or appearing in the console. A per-run marker gates each live stream and trims buffered output, with a leading newline to separate unterminated banners. - Detect the actual terminal in zsh when emitting markers so allocation fallback marks both pipes. Retain startup diagnostics if the shell exits before emitting a marker. Tests simulate startup at the runner boundary without editing system files.
2026-09-15 — Drain terminal output before closing the replica
- Darwin discards unread terminal output when the last replica descriptor closes.
BrewCommandServicemust keep its replica open until draining finishes, including cancellation and launch failure. Close both descriptors in onedefer; finish the drain only after a quiet poll that began after child exit. - The regression test holds the output observer until the child is reaped, then checks buffered and streamed stdout/stderr. This reproduces the output loss without depending on CI scheduling or adding a production test hook.