2026-09-04 The Brewfile That Passed Because It Was Empty
What I set out to do
Answer whether the dotfiles declare Homebrew apps, and when the answer was no (Tier 1.5 in AGENTS.md was “Manual”), make it declarative: a Brewfile that works with hm switch, with update, and only on Darwin.
What I actually did
Inventoried the live install with brew leaves, brew list --cask, and brew bundle dump: two formulae (pam-reattach, firefoxpwa) and nineteen casks, no taps, no mas. The dump also listed 37 VS Code extensions, which are not Homebrew’s and were left out.
Wrote programs.homebrew as a Home Manager module (nix/home-manager/modules/homebrew.nix) rather than adopting nix-darwin for one file. It renders taps/brews/casks into ~/.config/homebrew/Brewfile, exports HOMEBREW_BUNDLE_FILE so bare brew bundle finds it from any shell, and runs brew bundle install --no-upgrade at activation with HOMEBREW_NO_AUTO_UPDATE set, so a switch converges the set without touching the network. onActivation.cleanup (none/uninstall/zap, mirroring nix-darwin’s names) defaults to none. Whole config block is mkIf (enable && isDarwin).
Split the declaration along the profile/host axis from ADR 0021: home.nix carries the app set, hosts/atlas.nix adds the hardware casks (displaylink, logi-options+). The lists merge.
update gained the upgrade pass after the switch (brew update then brew bundle install, which upgrades by default), a brew outdated block in --preview, brew cleanup --prune=all under --cleanup, and a Homebrew changes block in the summary. Keyed on HOMEBREW_BUNDLE_FILE, so a host without the module skips it instead of brew guessing a Brewfile. brew joined the fake.external list in shell.nix since it must stay Homebrew’s own binary.
Tests first: nix/tests/homebrew.nix builds a known-state fixture via extendModules with mkForce’d lists and asserts the exact rendered text, the session variable, and the activation entry on Darwin, and their absence on the Linux configurations. Two new cases in the update ordering pytest suite assert the bundle step follows the switch and opts out without the variable. All green; just check green after the formatters ran; hm switch applied cleanly. Commit d7679f4.
What was striking
The verification nearly lied. I rendered the Brewfile with nix eval --expr 'builtins.getFlake "git+file://..."' and 2>/dev/null, and the eval failed silently (getFlake on an unlocked URL needs --impure). The output file was empty, and brew bundle check against an empty Brewfile reported “dependencies are satisfied”. Only the paired brew bundle cleanup dry-run gave it away by offering to uninstall every cask on the machine. Same lesson as 2026-07-19 Capability Flags Were Never Tested and the null-result rule: a passing check needs a control that would have failed. Re-run with path: and --impure, the real Brewfile matched the live install exactly (cleanup dry-run: nothing undeclared) and check flagged five casks as merely outdated, which is the upgrade pass’s job, not activation’s.
Homebrew 6’s brew bundle reads ${XDG_CONFIG_HOME}/homebrew/Brewfile for --global, and since the repo root is $XDG_CONFIG_HOME, the deployed symlink lands inside the checkout and needed a .gitignore line like direnv/ and mcp/.
Later: the flag that did nothing
Asked why the module had enable at all, and whether it could just be a module. Went back to the vault before answering. 2026-07-20 lib.uv Refactor and athena 26.05 Pin had recorded that dendritic treats boolean enable options as an anti-pattern and that the desktop profile’s listFilesRecursive “forces the flags”, so the capability model is “the tax auto-discovery charges”. I had paid that tax on reflex.
For this module the flag was redundant in fact, not only in principle. The desktop profile set it true unconditionally, the server profile does not import the module, and Linux is excluded by isDarwin. There was no configuration in which enable = false did anything.
The 07-20 line that the no-enable stance is “incompatible with auto-discovery by construction” is overstated. Auto-discovery forces an inert default, not a boolean. A non-empty declaration is one: home.file and launchd.agents are inert when empty, and so is a Brewfile with nothing in it. Dropped the option and gated on taps ++ brews ++ casks != []. Importing plus declaring enables Homebrew; a host that wants none declares none. This is the dendritic reading applied per module, and it does not reopen the 08-04 “not now” decision, which was about the ~50-module restructure, not this shape.
Test first again: tests/homebrew.nix gained an empty-declaration fixture asserting nothing deploys, which is a direct proof of inertness where the old check only proved the flag was wired. Red before the change, green after. The rendered Brewfile and the activation entry are byte-identical to the previous generation; the only diff in the new generation is the update script’s log message, confirmed by diffing the two home-manager-files trees. Commit ae0d70b.
Where a flag still belongs: modules whose “off” must survive being declared, like programs.litellm (the arrow-cpp puller from 2026-07-19 Capability Flags Were Never Tested), or anything a capability flag should be able to switch off. local.desktopApps.enable was the candidate existing flag here and is the wrong granularity: pam-reattach and athena’s cloudflared connector are Homebrew but not desktop apps.
Later still: four runs of update
Ran update to exercise the upgrade pass. It took four runs, and only the first failure was Homebrew’s.
Run 1 went green and skipped the pass: “No Brewfile declared”. The step keyed on HOMEBREW_BUNDLE_FILE, which hm-session-vars.sh exports at shell start, so the shell that ran the switch introducing it (and every older shell) never saw it. A step gated on a session variable skips silently on exactly the run that should first exercise it. Rekeyed on the deployed file at $XDG_CONFIG_HOME/homebrew/Brewfile, with the variable as an override only. The ordering suite’s skip test then caught a second bug in my first version: the probe returned non-zero on an absent file and set -e aborted the whole update instead of skipping. Commit 2af1b69.
Run 2 died in the switch on mcp-image: the unpinned mcp-image-src input moved and the fixed-output pnpm hash went stale, exactly as the package comment predicts. Bumped to the printed hash (commit 7490ef9). One trap here: the lock bump was already in the working tree and the hash now matched the new rev, so reverting the lock was no longer a way out. The tree could only move forward.
Run 3 died in the switch on LiteLLM 1.97.0 to 1.98.0. Followed 2026-08-20 LiteLLM Patch Stack Rebase onto 1.97.0’s playbook: scratch prev/work trees, sequential apply. Only one hunk had rotted (upstream inserted a namespace tool branch between the function branch and custom), regenerated from the pair. litellm-fastapi-flat-dependant.patch was retired outright: 1.98.0 filters get_flat_params by ParamTypes.query itself, so its marker now asserts the pristine shape. The tree-wide get_flat_dependant sweep then failed on upstream’s own comment naming the removed symbol; it now skips text after #. Dependency diff was pin bumps only. Commit dbb0125.
Run 4 green end to end. The Homebrew pass ran (brew update, brew bundle install), locks committed, LiteLLM 1.98.0 up with a single run and no crash loop, and a streamed glm-5.3-flash completion returned usage.cost through the patch stack. The six outdated casks (chatgpt, firefox, firefox@nightly, google-chrome, obs, spotify) had already been upgraded when I drove upgrade_homebrew by hand between runs 3 and 4 to exercise it in isolation, so run 4’s pass was a no-op.
Two more verification traps for the record. The hand-driven pass looked like it produced an empty change summary; a stubbed-inventory control proved the diff logic fine and the culprit was my own log filter dropping indented lines. And a concurrent session was committing SigNoz work on the same branch throughout: my earlier git add -A had staged its dashboard edit, so every commit from then on used explicit pathspecs.
Later: the ADR, the stale guidance, and Slack
Wrote docs/decisions/0026-homebrew-brewfile-home-manager-module.md: context (Tier 1.5 had no governing file; casks and fixed-path formulae are what the store cannot hold), the decisions (HM module not nix-darwin, declaration as the switch, profile/host split, two passes, cleanup none, the contract tests), an alternatives table, and consequences including what stays undeclared. The undeclared set turned out larger than I expected when I diffed /Applications against the casks: fourteen App Store apps (Bitwarden, WhatsApp, WireGuard, The Unarchiver, the iWork suite) and four direct downloads that have casks (Claude, Discord, Docker Desktop, Zen Browser). Casks and live install matched exactly; the gaps are all outside Homebrew’s view. Recorded as open threads (mas list, cask re-installs), not fixed.
Then swept for guidance the module made stale: the migration guide’s “dump brew list to ~/Documents” became brew bundle check plus the cleanup dry run; “install arm64 pam-reattach by hand” became hm switch; the dual-installation section no longer says brew install [package]; bootstrap’s Apple Silicon notice mentions the Brewfile; tailscale-serve.nix and bettertouchtool.nix comments point at programs.homebrew.casks instead of brew install --cask; athena’s cloudflared comment says why the module deploys nothing there; the atuin seed history swaps brew upgrade for the bundle check commands since upgrades go through update now. Claude Code’s allow list gained brew bundle check and brew bundle list (read-only; cleanup left out because the same pattern would admit --force). Switched and verified the rule landed in settings.json. Commit after just check.
Slack: not installed, not a cask, not in the pre-module inventory; only a ~/Library/Application Support/Slack remnant from before the migration (the /Users/aman residue from 2026-07-22 Stale-Path Audit and the Test Gate That Ran One Test). If it comes back it belongs in programs.homebrew.casks, not nixpkgs: nixpkgs’ darwin build is an undmg copy into the store (4.51.180, already behind the cask’s 4.52.155) that copyApps would then fight with Slack’s own updater, the same failure shape as Zotero. On the corp Linux boxes nixpkgs only builds it for x86_64-linux and IT manages it anyway. Left the one-line addition to a deliberate choice rather than installing an app that was removed at some point. The choice came back “add it”: one line in home.nix, and the switch’s activation pass installed Slack.app with no other change. First real install through the module rather than a converge of what was already there.
Top 3 tomorrow
- Decide whether
onActivation.cleanup = "uninstall"is wanted after a few clean switches. - Consider the same treatment for athena’s Homebrew-managed cloudflared connector, which is deliberately outside the flake today.
- athena needs its own
hm switchagainst the new lock (litellm is not in its profile, but nixpkgs-x86-darwin moved too). - Decide on Slack (cask if yes) and whether Claude/Discord/Docker/Zen move under the declaration as casks.
Related
Dotfiles Host Profiles via Private Flake, 2026-07-19 Capability Flags Were Never Tested, 2026-06-21 Tailscale Serve for Open WebUI (the cask that made Homebrew unavoidable), MacBook M4 2025 Migration